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#[derive(Debug, Clone, Copy)]
23pub struct SourceFile<'a> {
24 pub source_code: &'a str,
26 pub path: &'a str,
28}
29
30impl<'a> SourceFile<'a> {
31 #[must_use]
33 pub fn new(source_code: &'a str, path: &'a str) -> Self {
34 Self { source_code, path }
35 }
36}
37
38#[derive(Debug, Default, Clone, Copy)]
40pub struct TypeCheckContext<'a> {
41 pub stubs: Option<&'a SourceFile<'a>>,
43 pub module_stubs: &'a [ModuleStub],
45 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 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 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 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 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 let mut diagnostics = check_file_unwrap(&self.db, self.db.program_file(main_file));
133 diagnostics.retain(filter_diagnostics);
134 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 self.write_root_file(&main_path, main_source)?;
146 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 for ann in diagnostic.annotations_mut() {
153 adjust_annotation_span(ann, main_file, offset, source_len);
154 }
155 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 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 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 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
205fn adjust_annotation_span(ann: &mut Annotation, main_file: File, offset: TextSize, source_len: TextSize) {
215 let span = ann.get_span();
216 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
228pub struct TypeCheckingDiagnostics<'a> {
236 diagnostics: Vec<Diagnostic>,
238 type_checker: &'a TypeChecker,
240 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
265fn 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
301fn 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
309fn 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
320pub(crate) fn to_string(err: impl fmt::Display) -> String {
322 err.to_string()
323}