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}