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}