Red Knot Playground (#12681)

## Summary

This PR adds a playground for Red Knot

[Screencast from 2024-08-14
10-33-54.webm](https://github.com/user-attachments/assets/ae81d85f-74a3-4ba6-bb61-4a871b622f05)

Sharing does work 😆 I just forgot to start wrangler. 


It supports:

* Multiple files
* Showing the AST
* Showing the tokens
* Sharing
* Persistence to local storage

Future extensions:

* Configuration support: The `pyproject.toml` would *just* be another
file.
* Showing type information on hover

## Blockers

~~Salsa uses `catch_unwind` to break cycles, which Red Knot uses
extensively when inferring types in the standard library.
However, WASM (at least `wasm32-unknown-unknown`) doesn't support
`catch_unwind` today, so the playground always crashes when the type
inference encounters a cycle.~~

~~I created a discussion in the [salsa
zulip](https://salsa.zulipchat.com/#narrow/stream/333573-salsa-3.2E0/topic/WASM.20support)
to see if it would be possible to **not** use catch unwind to break
cycles.~~

~~[Rust tracking issue for WASM catch unwind
support](https://github.com/rust-lang/rust/issues/118168)~~

~~I tried to build the WASM with the nightly compiler option but ran
into problems because wasm-bindgen doesn't support WASM-exceptions. We
could try to write the binding code by hand.~~

~~Another alternative is to use `wasm32-unknown-emscripten` but it's
rather painful to build~~
This commit is contained in:
Micha Reiser 2025-03-18 17:17:11 +01:00 committed by GitHub
parent dcf31c9348
commit c027979851
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 2347 additions and 491 deletions

View file

@ -22,8 +22,10 @@ default = ["console_error_panic_hook"]
red_knot_project = { workspace = true, default-features = false, features = ["deflate"] }
ruff_db = { workspace = true, default-features = false, features = [] }
ruff_python_ast = { workspace = true }
ruff_notebook = { workspace = true }
ruff_python_ast = { workspace = true }
ruff_source_file = { workspace = true }
ruff_text_size = { workspace = true }
console_error_panic_hook = { workspace = true, optional = true }
console_log = { workspace = true }

View file

@ -1,20 +1,23 @@
use std::any::Any;
use js_sys::Error;
use wasm_bindgen::prelude::*;
use js_sys::{Error, JsString};
use red_knot_project::metadata::options::{EnvironmentOptions, Options};
use red_knot_project::metadata::value::RangedValue;
use red_knot_project::watch::{ChangeEvent, ChangedKind, CreatedKind, DeletedKind};
use red_knot_project::ProjectMetadata;
use red_knot_project::{Db, ProjectDatabase};
use ruff_db::diagnostic::{DisplayDiagnosticConfig, OldDiagnosticTrait};
use ruff_db::files::{system_path_to_file, File};
use ruff_db::source::{line_index, source_text};
use ruff_db::system::walk_directory::WalkDirectoryBuilder;
use ruff_db::system::{
CaseSensitivity, DirectoryEntry, GlobError, MemoryFileSystem, Metadata, PatternError, System,
SystemPath, SystemPathBuf, SystemVirtualPath,
};
use ruff_db::Upcast;
use ruff_notebook::Notebook;
use ruff_source_file::SourceLocation;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(start)]
pub fn run() {
@ -36,6 +39,7 @@ pub fn run() {
pub struct Workspace {
db: ProjectDatabase,
system: WasmSystem,
options: Options,
}
#[wasm_bindgen]
@ -47,34 +51,49 @@ impl Workspace {
let mut workspace =
ProjectMetadata::discover(SystemPath::new(root), &system).map_err(into_error)?;
workspace.apply_cli_options(Options {
let options = Options {
environment: Some(EnvironmentOptions {
python_version: Some(RangedValue::cli(settings.python_version.into())),
..EnvironmentOptions::default()
}),
..Options::default()
});
};
workspace.apply_cli_options(options.clone());
let db = ProjectDatabase::new(workspace, system.clone()).map_err(into_error)?;
Ok(Self { db, system })
Ok(Self {
db,
system,
options,
})
}
#[wasm_bindgen(js_name = "openFile")]
pub fn open_file(&mut self, path: &str, contents: &str) -> Result<FileHandle, Error> {
let path = SystemPath::new(path);
self.system
.fs
.write_file_all(path, contents)
.map_err(into_error)?;
self.db.apply_changes(
vec![ChangeEvent::Created {
path: path.to_path_buf(),
kind: CreatedKind::File,
}],
Some(&self.options),
);
let file = system_path_to_file(&self.db, path).expect("File to exist");
file.sync(&mut self.db);
self.db.project().open_file(&mut self.db, file);
Ok(FileHandle {
file,
path: SystemPath::new(path).to_path_buf(),
path: path.to_path_buf(),
})
}
@ -89,7 +108,19 @@ impl Workspace {
.write_file(&file_id.path, contents)
.map_err(into_error)?;
file_id.file.sync(&mut self.db);
self.db.apply_changes(
vec![
ChangeEvent::Changed {
path: file_id.path.to_path_buf(),
kind: ChangedKind::FileContent,
},
ChangeEvent::Changed {
path: file_id.path.to_path_buf(),
kind: ChangedKind::FileMetadata,
},
],
Some(&self.options),
);
Ok(())
}
@ -104,32 +135,30 @@ impl Workspace {
.remove_file(&file_id.path)
.map_err(into_error)?;
file.sync(&mut self.db);
self.db.apply_changes(
vec![ChangeEvent::Deleted {
path: file_id.path.to_path_buf(),
kind: DeletedKind::File,
}],
Some(&self.options),
);
Ok(())
}
/// Checks a single file.
#[wasm_bindgen(js_name = "checkFile")]
pub fn check_file(&self, file_id: &FileHandle) -> Result<Vec<String>, Error> {
pub fn check_file(&self, file_id: &FileHandle) -> Result<Vec<Diagnostic>, Error> {
let result = self.db.check_file(file_id.file).map_err(into_error)?;
let display_config = DisplayDiagnosticConfig::default().color(false);
Ok(result
.into_iter()
.map(|diagnostic| diagnostic.display(&self.db, &display_config).to_string())
.collect())
Ok(result.into_iter().map(Diagnostic::wrap).collect())
}
/// Checks all open files
pub fn check(&self) -> Result<Vec<String>, Error> {
pub fn check(&self) -> Result<Vec<Diagnostic>, Error> {
let result = self.db.check().map_err(into_error)?;
let display_config = DisplayDiagnosticConfig::default().color(false);
Ok(result
.into_iter()
.map(|diagnostic| diagnostic.display(&self.db, &display_config).to_string())
.collect())
Ok(result.into_iter().map(Diagnostic::wrap).collect())
}
/// Returns the parsed AST for `path`
@ -185,6 +214,124 @@ impl Settings {
}
}
#[wasm_bindgen]
pub struct Diagnostic {
#[wasm_bindgen(readonly)]
inner: Box<dyn OldDiagnosticTrait>,
}
#[wasm_bindgen]
impl Diagnostic {
fn wrap(diagnostic: Box<dyn OldDiagnosticTrait>) -> Self {
Self { inner: diagnostic }
}
#[wasm_bindgen]
pub fn message(&self) -> JsString {
JsString::from(&*self.inner.message())
}
#[wasm_bindgen]
pub fn id(&self) -> JsString {
JsString::from(self.inner.id().to_string())
}
#[wasm_bindgen]
pub fn severity(&self) -> Severity {
Severity::from(self.inner.severity())
}
#[wasm_bindgen]
pub fn text_range(&self) -> Option<TextRange> {
self.inner
.span()
.and_then(|span| Some(TextRange::from(span.range()?)))
}
#[wasm_bindgen]
pub fn to_range(&self, workspace: &Workspace) -> Option<Range> {
self.inner.span().and_then(|span| {
let line_index = line_index(workspace.db.upcast(), span.file());
let source = source_text(workspace.db.upcast(), span.file());
let text_range = span.range()?;
Some(Range {
start: line_index
.source_location(text_range.start(), &source)
.into(),
end: line_index.source_location(text_range.end(), &source).into(),
})
})
}
#[wasm_bindgen]
pub fn display(&self, workspace: &Workspace) -> JsString {
let config = DisplayDiagnosticConfig::default().color(false);
self.inner
.display(workspace.db.upcast(), &config)
.to_string()
.into()
}
}
#[wasm_bindgen]
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub struct Range {
pub start: Position,
pub end: Position,
}
impl From<SourceLocation> for Position {
fn from(location: SourceLocation) -> Self {
Self {
line: location.row.to_zero_indexed(),
character: location.column.to_zero_indexed(),
}
}
}
#[wasm_bindgen]
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub struct Position {
pub line: usize,
pub character: usize,
}
#[wasm_bindgen]
#[derive(Copy, Clone, Hash, PartialEq, Eq)]
pub enum Severity {
Info,
Warning,
Error,
Fatal,
}
impl From<ruff_db::diagnostic::Severity> for Severity {
fn from(value: ruff_db::diagnostic::Severity) -> Self {
match value {
ruff_db::diagnostic::Severity::Info => Self::Info,
ruff_db::diagnostic::Severity::Warning => Self::Warning,
ruff_db::diagnostic::Severity::Error => Self::Error,
ruff_db::diagnostic::Severity::Fatal => Self::Fatal,
}
}
}
#[wasm_bindgen]
pub struct TextRange {
pub start: u32,
pub end: u32,
}
impl From<ruff_text_size::TextRange> for TextRange {
fn from(value: ruff_text_size::TextRange) -> Self {
Self {
start: value.start().into(),
end: value.end().into(),
}
}
}
#[wasm_bindgen]
#[derive(Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum PythonVersion {

View file

@ -2,7 +2,7 @@
use wasm_bindgen_test::wasm_bindgen_test;
use red_knot_wasm::{PythonVersion, Settings, Workspace};
use red_knot_wasm::{Position, PythonVersion, Settings, Workspace};
#[wasm_bindgen_test]
fn check() {
@ -17,17 +17,17 @@ fn check() {
let result = workspace.check().expect("Check to succeed");
assert_eq!(result.len(), 1);
let diagnostic = &result[0];
assert_eq!(diagnostic.id(), "lint:unresolved-import");
assert_eq!(
result,
vec![
"\
error: lint:unresolved-import
--> /test.py:1:8
|
1 | import random22
| ^^^^^^^^ Cannot resolve import `random22`
|
",
],
diagnostic.to_range(&workspace).unwrap().start,
Position {
line: 0,
character: 7
}
);
assert_eq!(diagnostic.message(), "Cannot resolve import `random22`");
}