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