use std::fmt::{self, Write as _};
use monty_types::{ModuleStub, TypeCheckingConfig, TypeCheckingFormat};
use ruff_db::{
Db as _,
diagnostic::{
Annotation, Diagnostic, DiagnosticFormat, DiagnosticId, DisplayDiagnosticConfig, DisplayDiagnostics,
UnifiedFile,
},
file_revision::FileRevision,
files::{File, system_path_to_file},
system::{DbWithWritableSystem as _, SystemPath, SystemPathBuf},
};
use ruff_python_stdlib::identifiers::is_identifier;
use ruff_text_size::{TextRange, TextSize};
use salsa::Setter as _;
use ty_python_semantic::{Db as _, check_file_unwrap};
use crate::db::{MemoryDb, SRC_ROOT};
#[derive(Debug, Clone, Copy)]
pub struct SourceFile<'a> {
pub source_code: &'a str,
pub path: &'a str,
}
impl<'a> SourceFile<'a> {
#[must_use]
pub fn new(source_code: &'a str, path: &'a str) -> Self {
Self { source_code, path }
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct TypeCheckContext<'a> {
pub stubs: Option<&'a SourceFile<'a>>,
pub module_stubs: &'a [ModuleStub],
pub prelude: &'a str,
}
#[derive(Debug, Default)]
pub struct TypeChecker {
db: MemoryDb,
next_revision: u128,
}
impl TypeChecker {
pub fn run<'a>(
&'a mut self,
python_source: &SourceFile<'_>,
stubs_file: Option<&SourceFile<'_>>,
config: TypeCheckingConfig,
) -> Result<Option<TypeCheckingDiagnostics<'a>>, String> {
let context = TypeCheckContext {
stubs: stubs_file,
..TypeCheckContext::default()
};
self.run_with(python_source, &context, config)
}
pub fn run_with<'a>(
&'a mut self,
python_source: &SourceFile<'_>,
context: &TypeCheckContext<'_>,
config: TypeCheckingConfig,
) -> Result<Option<TypeCheckingDiagnostics<'a>>, String> {
let src_root = SystemPathBuf::from(SRC_ROOT);
let main_path = src_root.join(python_source.path);
let main_source = python_source.source_code;
for (path, source) in stub_files(context.module_stubs)? {
self.write_root_file(&src_root.join(path), source)?;
}
let mut prefix = context.prelude.to_owned();
if !prefix.is_empty() && !prefix.ends_with('\n') {
prefix.push('\n');
}
if let Some(stubs_file) = context.stubs {
let stubs_path = src_root.join(stubs_file.path);
let stubs = self.write_root_file(&stubs_path, stubs_file.source_code)?;
if system_path_to_file(&self.db, &main_path).is_ok_and(|file| file == stubs) {
return Err(format!(
"script `{}` collides with the type stubs file `{}`",
python_source.path, stubs_file.path
));
}
let stub_stem = stubs_file
.path
.split_once('.')
.map_or(stubs_file.path, |(before, _)| before);
writeln!(prefix, "from {stub_stem} import *").map_err(to_string)?;
}
let (main_file, code_offset): (File, u32) = if prefix.is_empty() {
(self.write_root_file(&main_path, main_source)?, 0)
} else {
let offset = u32::try_from(prefix.len()).map_err(to_string)?;
prefix.push_str(main_source);
(self.write_root_file(&main_path, &prefix)?, offset)
};
let mut diagnostics = check_file_unwrap(&self.db, self.db.program_file(main_file));
diagnostics.retain(filter_diagnostics);
let prefix_offset = TextSize::new(code_offset);
diagnostics.retain(|diagnostic| !starts_in_prefix(diagnostic, main_file, prefix_offset));
if diagnostics.is_empty() {
Ok(None)
} else {
self.write_root_file(&main_path, main_source)?;
if code_offset > 0 {
let offset = TextSize::new(code_offset);
let source_len = TextSize::try_from(main_source.len()).map_err(to_string)?;
for diagnostic in &mut diagnostics {
for ann in diagnostic.annotations_mut() {
adjust_annotation_span(ann, main_file, offset, source_len);
}
for sub in diagnostic.sub_diagnostics_mut() {
for ann in sub.annotations_mut() {
adjust_annotation_span(ann, main_file, offset, source_len);
}
}
}
}
diagnostics.sort_by(|a, b| a.rendering_sort_key(&self.db).cmp(&b.rendering_sort_key(&self.db)));
Ok(Some(TypeCheckingDiagnostics {
diagnostics,
type_checker: self,
config,
}))
}
}
fn write_root_file(&mut self, path: &SystemPathBuf, source: &str) -> Result<File, String> {
self.db.write_file(path, source).map_err(to_string)?;
self.bump_revisions(path);
system_path_to_file(&self.db, path).map_err(to_string)
}
fn bump_revisions(&mut self, path: &SystemPath) {
let mut files = Vec::new();
let mut current = Some(path);
while let Some(path) = current {
if let Some(file) = self.db.files().try_system(&self.db, path) {
files.push(file);
}
current = path.parent();
}
self.next_revision = self.next_revision.wrapping_add(1);
for file in files {
file.set_revision(&mut self.db)
.to(FileRevision::new(self.next_revision));
}
}
}
fn adjust_annotation_span(ann: &mut Annotation, main_file: File, offset: TextSize, source_len: TextSize) {
let span = ann.get_span();
if let UnifiedFile::Ty(span_file) = span.file()
&& *span_file == main_file
&& let Some(range) = span.range()
{
let shift = |position: TextSize| position.checked_sub(offset).unwrap_or_default().min(source_len);
let new_range = TextRange::new(shift(range.start()), shift(range.end()));
let new_span = span.clone().with_range(new_range);
ann.set_span(new_span);
}
}
pub struct TypeCheckingDiagnostics<'a> {
diagnostics: Vec<Diagnostic>,
type_checker: &'a TypeChecker,
config: TypeCheckingConfig,
}
impl fmt::Display for TypeCheckingDiagnostics<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let format = match self.config.format {
TypeCheckingFormat::Full => DiagnosticFormat::Full,
TypeCheckingFormat::Concise => DiagnosticFormat::Concise,
TypeCheckingFormat::Azure => DiagnosticFormat::Azure,
TypeCheckingFormat::Json => DiagnosticFormat::Json,
TypeCheckingFormat::JsonLines => DiagnosticFormat::JsonLines,
TypeCheckingFormat::Rdjson => DiagnosticFormat::Rdjson,
TypeCheckingFormat::Pylint => DiagnosticFormat::Pylint,
TypeCheckingFormat::Gitlab => DiagnosticFormat::Gitlab,
TypeCheckingFormat::Github => DiagnosticFormat::Github,
};
let config = DisplayDiagnosticConfig::new("monty")
.format(format)
.color(self.config.color);
DisplayDiagnostics::new(&self.type_checker.db, &config, &self.diagnostics).fmt(f)
}
}
fn stub_files(stubs: &[ModuleStub]) -> Result<Vec<(String, &str)>, String> {
let mut files: Vec<(String, &str)> = Vec::new();
let is_package = |module: &str| {
stubs
.iter()
.any(|stub| stub.module().starts_with(module) && stub.module()[module.len()..].starts_with('.'))
};
for stub in stubs {
let parts: Vec<&str> = stub.module().split('.').collect();
if parts.iter().any(|part| !is_identifier(part)) {
return Err(format!("invalid module name {:?}", stub.module()));
}
for depth in 1..parts.len() {
let ancestor = parts[..depth].join(".");
if !stubs.iter().any(|other| other.module() == ancestor) {
let init = format!("{}/__init__.pyi", parts[..depth].join("/"));
if !files.iter().any(|(path, _)| *path == init) {
files.push((init, ""));
}
}
}
let dir = parts.join("/");
let path = if is_package(stub.module()) {
format!("{dir}/__init__.pyi")
} else {
format!("{dir}.pyi")
};
files.push((path, stub.source()));
}
Ok(files)
}
fn starts_in_prefix(diagnostic: &Diagnostic, main_file: File, offset: TextSize) -> bool {
diagnostic.primary_span().is_some_and(|span| {
matches!(span.file(), UnifiedFile::Ty(file) if *file == main_file)
&& span.range().is_some_and(|range| range.start() < offset)
})
}
fn filter_diagnostics(d: &Diagnostic) -> bool {
!(matches!(d.id(), DiagnosticId::InvalidSyntax)
&& matches!(
d.headline_message(),
"`await` statement outside of a function" | "`await` outside of an asynchronous function"
))
}
pub(crate) fn to_string(err: impl fmt::Display) -> String {
err.to_string()
}