Skip to main content

monty_type_checking/
type_check.rs

1use std::fmt::{self, Write as _};
2
3use monty_types::{ModuleStub, 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::{DbWithWritableSystem as _, SystemPath, SystemPathBuf},
13};
14use ruff_python_stdlib::identifiers::is_identifier;
15use ruff_text_size::{TextRange, TextSize};
16use salsa::Setter as _;
17use ty_python_semantic::{Db as _, check_file_unwrap};
18
19use crate::db::{MemoryDb, SRC_ROOT};
20
21/// Definition of a source file.
22#[derive(Debug, Clone, Copy)]
23pub struct SourceFile<'a> {
24    /// source code
25    pub source_code: &'a str,
26    /// file path
27    pub path: &'a str,
28}
29
30impl<'a> SourceFile<'a> {
31    /// Create a new source file.
32    #[must_use]
33    pub fn new(source_code: &'a str, path: &'a str) -> Self {
34        Self { source_code, path }
35    }
36}
37
38/// Session declarations and imports for [`TypeChecker::run_with`].
39#[derive(Debug, Default, Clone, Copy)]
40pub struct TypeCheckContext<'a> {
41    /// Host stubs and committed snippets, star-imported into the snippet.
42    pub stubs: Option<&'a SourceFile<'a>>,
43    /// Host module stubs, written as `.pyi` modules or packages and never star-imported.
44    pub module_stubs: &'a [ModuleStub],
45    /// Committed imports injected before the star import; see [`crate::scan_imports`].
46    pub prelude: &'a str,
47}
48
49#[derive(Debug, Default)]
50pub struct TypeChecker {
51    db: MemoryDb,
52    next_revision: u128,
53}
54
55impl TypeChecker {
56    /// Type check some python source code, checking if it's valid to run with monty.
57    ///
58    /// # Arguments
59    /// * `python_source` - The python source code to type check.
60    /// * `stubs_file` - Optional stubs file to use for type checking.
61    /// * `config` - Configuration for the type checking diagnostics.
62    ///
63    /// # Returns
64    /// * `Ok(None)` - If there are no typing errors.
65    /// * `Ok(Some(string))` - If there are typing errors.
66    /// * `Err(String)` - If there was an unexpected/internal error during type checking, or the
67    ///   script path names the stubs file.
68    pub fn run<'a>(
69        &'a mut self,
70        python_source: &SourceFile<'_>,
71        stubs_file: Option<&SourceFile<'_>>,
72        config: TypeCheckingConfig,
73    ) -> Result<Option<TypeCheckingDiagnostics<'a>>, String> {
74        let context = TypeCheckContext {
75            stubs: stubs_file,
76            ..TypeCheckContext::default()
77        };
78        self.run_with(python_source, &context, config)
79    }
80
81    /// [`run`](Self::run) with host module stubs and committed imports.
82    /// The prelude precedes the stubs' star import so later bindings shadow imports as at runtime.
83    /// Diagnostics use the snippet's original line numbers.
84    pub fn run_with<'a>(
85        &'a mut self,
86        python_source: &SourceFile<'_>,
87        context: &TypeCheckContext<'_>,
88        config: TypeCheckingConfig,
89    ) -> Result<Option<TypeCheckingDiagnostics<'a>>, String> {
90        let src_root = SystemPathBuf::from(SRC_ROOT);
91        let main_path = src_root.join(python_source.path);
92        let main_source = python_source.source_code;
93
94        for (path, source) in stub_files(context.module_stubs)? {
95            self.write_root_file(&src_root.join(path), source)?;
96        }
97
98        let mut prefix = context.prelude.to_owned();
99        if !prefix.is_empty() && !prefix.ends_with('\n') {
100            prefix.push('\n');
101        }
102        if let Some(stubs_file) = context.stubs {
103            let stubs_path = src_root.join(stubs_file.path);
104            let stubs = self.write_root_file(&stubs_path, stubs_file.source_code)?;
105            // Writing the script over its own stubs would drop them and make it import
106            // itself; compare interned files, not strings, since the db normalises paths.
107            if system_path_to_file(&self.db, &main_path).is_ok_and(|file| file == stubs) {
108                return Err(format!(
109                    "script `{}` collides with the type stubs file `{}`",
110                    python_source.path, stubs_file.path
111                ));
112            }
113            let stub_stem = stubs_file
114                .path
115                .split_once('.')
116                .map_or(stubs_file.path, |(before, _)| before);
117            writeln!(prefix, "from {stub_stem} import *").map_err(to_string)?;
118        }
119        // Subtract the injected prefix from diagnostic spans to preserve the original positions.
120        let (main_file, code_offset): (File, u32) = if prefix.is_empty() {
121            (self.write_root_file(&main_path, main_source)?, 0)
122        } else {
123            let offset = u32::try_from(prefix.len()).map_err(to_string)?;
124            prefix.push_str(main_source);
125            (self.write_root_file(&main_path, &prefix)?, offset)
126        };
127
128        // Use `check_file_unwrap` (not `check_types` alone) so that parser errors
129        // and unsupported-syntax errors are included — otherwise malformed input
130        // (e.g. deeply nested parentheses that ruff's parser rejects) would silently
131        // type-check clean.
132        let mut diagnostics = check_file_unwrap(&self.db, self.db.program_file(main_file));
133        diagnostics.retain(filter_diagnostics);
134        // Suppress prefix diagnostics: committed imports may have used `# type: ignore`
135        // or skipped type checking, and have no position in this snippet.
136        let prefix_offset = TextSize::new(code_offset);
137        diagnostics.retain(|diagnostic| !starts_in_prefix(diagnostic, main_file, prefix_offset));
138
139        if diagnostics.is_empty() {
140            Ok(None)
141        } else {
142            // without all this errors would appear on the wrong line because we injected `from type_stubs import *`
143
144            // if we injected the stubs import, we need to write the actual source back to the file in the database
145            self.write_root_file(&main_path, main_source)?;
146            // and then adjust each span in the error message to account for the injected stubs import
147            if code_offset > 0 {
148                let offset = TextSize::new(code_offset);
149                let source_len = TextSize::try_from(main_source.len()).map_err(to_string)?;
150                for diagnostic in &mut diagnostics {
151                    // Adjust spans in main diagnostic annotations (only for spans in the main file)
152                    for ann in diagnostic.annotations_mut() {
153                        adjust_annotation_span(ann, main_file, offset, source_len);
154                    }
155                    // Adjust spans in sub-diagnostic annotations (e.g., "info: Function defined here")
156                    for sub in diagnostic.sub_diagnostics_mut() {
157                        for ann in sub.annotations_mut() {
158                            adjust_annotation_span(ann, main_file, offset, source_len);
159                        }
160                    }
161                }
162            }
163            // Sort diagnostics by line number
164            diagnostics.sort_by(|a, b| a.rendering_sort_key(&self.db).cmp(&b.rendering_sort_key(&self.db)));
165
166            Ok(Some(TypeCheckingDiagnostics {
167                diagnostics,
168                type_checker: self,
169                config,
170            }))
171        }
172    }
173
174    /// Write a source file and invalidate cached queries for its path.
175    fn write_root_file(&mut self, path: &SystemPathBuf, source: &str) -> Result<File, String> {
176        self.db.write_file(path, source).map_err(to_string)?;
177        self.bump_revisions(path);
178
179        system_path_to_file(&self.db, path).map_err(to_string)
180    }
181
182    /// Force a fresh Salsa revision for `path` and its ancestor directories.
183    ///
184    /// `write_file` derives the revision from an mtime, and wasm's clock only
185    /// ticks once a millisecond, so two writes to one path can share a revision
186    /// and the second becomes invisible. Directories need it too — module
187    /// resolution keys off them when later feeds add module stubs.
188    fn bump_revisions(&mut self, path: &SystemPath) {
189        let mut files = Vec::new();
190        let mut current = Some(path);
191        while let Some(path) = current {
192            if let Some(file) = self.db.files().try_system(&self.db, path) {
193                files.push(file);
194            }
195            current = path.parent();
196        }
197        self.next_revision = self.next_revision.wrapping_add(1);
198        for file in files {
199            file.set_revision(&mut self.db)
200                .to(FileRevision::new(self.next_revision));
201        }
202    }
203}
204
205/// Adjust the span of an annotation by subtracting the given offset.
206///
207/// This is used when we inject a stub import at the beginning of the source code,
208/// and need to adjust all spans to account for the injected code.
209/// Only adjusts spans that belong to the main file being type-checked.
210///
211/// The result is clamped into the source: `TextSize` is a `u32`, so a span
212/// inside the injected import would wrap and panic the renderer, killing the
213/// session.
214fn adjust_annotation_span(ann: &mut Annotation, main_file: File, offset: TextSize, source_len: TextSize) {
215    let span = ann.get_span();
216    // Only adjust spans for the main file (not stubs or other files)
217    if let UnifiedFile::Ty(span_file) = span.file()
218        && *span_file == main_file
219        && let Some(range) = span.range()
220    {
221        let shift = |position: TextSize| position.checked_sub(offset).unwrap_or_default().min(source_len);
222        let new_range = TextRange::new(shift(range.start()), shift(range.end()));
223        let new_span = span.clone().with_range(new_range);
224        ann.set_span(new_span);
225    }
226}
227
228/// The diagnostics of one failed type check, rendered on `Display`.
229///
230/// Borrows the checker because ty's diagnostics only resolve their spans (file
231/// paths, source snippets) against the database that produced them — which is
232/// why the format is chosen up front, in [`TypeChecker::run`], and why callers
233/// that outlive the checker (anything across a process boundary) must keep the
234/// rendered string rather than this.
235pub struct TypeCheckingDiagnostics<'a> {
236    /// The actual diagnostic message
237    diagnostics: Vec<Diagnostic>,
238    /// The type checker, the db is needed to display diagnostics.
239    type_checker: &'a TypeChecker,
240    /// How to format the output and whether to use color.
241    config: TypeCheckingConfig,
242}
243
244impl fmt::Display for TypeCheckingDiagnostics<'_> {
245    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246        let format = match self.config.format {
247            TypeCheckingFormat::Full => DiagnosticFormat::Full,
248            TypeCheckingFormat::Concise => DiagnosticFormat::Concise,
249            TypeCheckingFormat::Azure => DiagnosticFormat::Azure,
250            TypeCheckingFormat::Json => DiagnosticFormat::Json,
251            TypeCheckingFormat::JsonLines => DiagnosticFormat::JsonLines,
252            TypeCheckingFormat::Rdjson => DiagnosticFormat::Rdjson,
253            TypeCheckingFormat::Pylint => DiagnosticFormat::Pylint,
254            TypeCheckingFormat::Gitlab => DiagnosticFormat::Gitlab,
255            TypeCheckingFormat::Github => DiagnosticFormat::Github,
256        };
257
258        let config = DisplayDiagnosticConfig::new("monty")
259            .format(format)
260            .color(self.config.color);
261        DisplayDiagnostics::new(&self.type_checker.db, &config, &self.diagnostics).fmt(f)
262    }
263}
264
265/// Maps stubs to root-relative `.pyi` paths, creating empty ancestor packages as needed.
266/// For example, `a.b` produces `a/__init__.pyi` and `a/b.pyi`.
267/// Revalidates identifiers because deserialization bypasses `ModuleStub::new`; otherwise
268/// path components could otherwise write outside the virtual source root.
269fn stub_files(stubs: &[ModuleStub]) -> Result<Vec<(String, &str)>, String> {
270    let mut files: Vec<(String, &str)> = Vec::new();
271    let is_package = |module: &str| {
272        stubs
273            .iter()
274            .any(|stub| stub.module().starts_with(module) && stub.module()[module.len()..].starts_with('.'))
275    };
276    for stub in stubs {
277        let parts: Vec<&str> = stub.module().split('.').collect();
278        if parts.iter().any(|part| !is_identifier(part)) {
279            return Err(format!("invalid module name {:?}", stub.module()));
280        }
281        for depth in 1..parts.len() {
282            let ancestor = parts[..depth].join(".");
283            if !stubs.iter().any(|other| other.module() == ancestor) {
284                let init = format!("{}/__init__.pyi", parts[..depth].join("/"));
285                if !files.iter().any(|(path, _)| *path == init) {
286                    files.push((init, ""));
287                }
288            }
289        }
290        let dir = parts.join("/");
291        let path = if is_package(stub.module()) {
292            format!("{dir}/__init__.pyi")
293        } else {
294            format!("{dir}.pyi")
295        };
296        files.push((path, stub.source()));
297    }
298    Ok(files)
299}
300
301/// Whether the primary span starts in the injected prelude or star import.
302fn starts_in_prefix(diagnostic: &Diagnostic, main_file: File, offset: TextSize) -> bool {
303    diagnostic.primary_span().is_some_and(|span| {
304        matches!(span.file(), UnifiedFile::Ty(file) if *file == main_file)
305            && span.range().is_some_and(|range| range.start() < offset)
306    })
307}
308
309/// Filter out diagnostics we want to ignore.
310///
311/// Should only be necessary until <https://github.com/astral-sh/ty/issues/2599> is fixed.
312fn filter_diagnostics(d: &Diagnostic) -> bool {
313    !(matches!(d.id(), DiagnosticId::InvalidSyntax)
314        && matches!(
315            d.headline_message(),
316            "`await` statement outside of a function" | "`await` outside of an asynchronous function"
317        ))
318}
319
320/// Convert a displayable error into the string type used throughout type checking.
321pub(crate) fn to_string(err: impl fmt::Display) -> String {
322    err.to_string()
323}