use std::{fmt, io::ErrorKind, mem};
use monty_types::{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::{DbWithTestSystem, DbWithWritableSystem as _, SystemPath, SystemPathBuf},
};
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};
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)]
pub struct TypeChecker {
db: MemoryDb,
touched_files: Vec<TouchedRootFile>,
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 src_root = SystemPathBuf::from(SRC_ROOT);
let main_path = src_root.join(python_source.path);
let main_source = python_source.source_code;
let (main_file, code_offset): (File, u32) = if let Some(stubs_file) = stubs_file {
let stubs_path = src_root.join(stubs_file.path);
self.write_root_file(&stubs_path, stubs_file.source_code)?;
let stub_stem = stubs_file
.path
.split_once('.')
.map_or(stubs_file.path, |(before, _)| before);
let mut new_source = format!("from {stub_stem} import *\n");
let offset = u32::try_from(new_source.len()).map_err(to_string)?;
new_source.push_str(main_source);
let main_file = self.write_root_file(&main_path, &new_source)?;
(main_file, offset)
} else {
let main_file = self.write_root_file(&main_path, main_source)?;
(main_file, 0)
};
let mut diagnostics = check_file_unwrap(&self.db, self.db.program_file(main_file));
diagnostics.retain(filter_diagnostics);
if diagnostics.is_empty() {
Ok(None)
} else {
self.rewrite_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);
let file = system_path_to_file(&self.db, path)
.unwrap_or_else(|e| panic!("interning a just-written root file must succeed, DB in an unsafe state: {e}"));
if !self.touched_files.iter().any(|t| &t.path == path) {
self.touched_files.push(TouchedRootFile::new(path.clone(), file));
}
Ok(file)
}
fn rewrite_root_file(&mut self, path: &SystemPathBuf, source: &str) -> Result<(), String> {
assert!(
self.touched_files.iter().any(|t| &t.path == path),
"rewrite_root_file called for untracked path '{path}' — must call write_root_file first",
);
self.db.write_file(path, source).map_err(to_string)?;
self.bump_revisions(path);
Ok(())
}
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));
}
}
pub fn reset(&mut self) -> Result<(), String> {
let touched_files = mem::take(&mut self.touched_files);
for touched in touched_files.iter().rev() {
touched.cleanup(&mut self.db)?;
}
File::sync_path(&mut self.db, &SystemPathBuf::from(SRC_ROOT));
Ok(())
}
}
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 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"
))
}
#[derive(Debug)]
struct TouchedRootFile {
path: SystemPathBuf,
file: File,
}
impl TouchedRootFile {
fn new(path: SystemPathBuf, file: File) -> Self {
Self { path, file }
}
fn cleanup(&self, db: &mut MemoryDb) -> Result<(), String> {
match db.memory_file_system().remove_file(&self.path) {
Ok(()) => {}
Err(err) if err.kind() == ErrorKind::NotFound => {}
Err(err) => {
return Err(format!(
"Failed to remove pooled type-check file '{}': {err}",
self.path
));
}
}
self.file.sync(db);
let src_root = SystemPathBuf::from(SRC_ROOT);
let mut ancestor = self.path.parent();
while let Some(dir) = ancestor
&& dir != src_root.as_path()
{
match db.memory_file_system().remove_directory(dir) {
Ok(()) => {}
Err(err) if err.to_string().contains("directory not empty") => break,
Err(err) => {
return Err(format!("Failed to remove pooled type-check directory '{dir}': {err}"));
}
}
File::sync_path(db, dir);
ancestor = dir.parent();
}
Ok(())
}
}
pub(crate) fn to_string(err: impl fmt::Display) -> String {
err.to_string()
}