Skip to main content

monty_types/
type_checking.rs

1use std::{
2    error::Error,
3    fmt::{self, Display},
4    str::FromStr,
5};
6
7pub use ruff_python_stdlib::identifiers::is_identifier;
8use serde::{Deserialize, Serialize};
9use strum::VariantNames;
10
11/// How type-check diagnostics are rendered into text.
12///
13/// Mirrors ty's `DiagnosticFormat`. Rendering happens wherever the type checker
14/// runs (inside the worker for pool sessions), because ty's structured
15/// diagnostics borrow the salsa database and cannot cross a process boundary —
16/// so the format has to be chosen before the check, not after it.
17///
18/// Serialized into session dumps by variant name, so a rename needs
19/// `#[serde(alias)]` to keep older dumps loading (see `DUMP_VERSION` in `monty`).
20#[derive(
21    Debug,
22    Clone,
23    Copy,
24    Default,
25    PartialEq,
26    Eq,
27    Serialize,
28    Deserialize,
29    strum::Display,
30    strum::EnumString,
31    strum::VariantNames,
32)]
33#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
34pub enum TypeCheckingFormat {
35    /// Human-readable diagnostics with a source snippet and carets.
36    #[default]
37    Full,
38    /// One `path:line:col: severity[rule] message` line per diagnostic.
39    Concise,
40    /// Azure Pipelines logging commands.
41    Azure,
42    /// A JSON array of diagnostic objects.
43    Json,
44    /// One JSON diagnostic object per line.
45    #[strum(to_string = "jsonlines", serialize = "json-lines")]
46    JsonLines,
47    /// Reviewdog diagnostic JSON.
48    Rdjson,
49    /// Pylint-compatible output.
50    Pylint,
51    /// GitLab Code Quality report JSON.
52    Gitlab,
53    /// GitHub Actions workflow commands.
54    Github,
55}
56
57impl TypeCheckingFormat {
58    /// Parses a format name, reporting the valid names on failure.
59    ///
60    /// Bindings take the format as a string, so the error has to be good
61    /// enough to show a user who guessed wrong.
62    pub fn from_name(name: &str) -> Result<Self, String> {
63        Self::from_str(name)
64            .map_err(|_| format!("unknown type check format '{name}', expected one of: {}", Self::names()))
65    }
66
67    /// Comma-separated list of the accepted format names.
68    #[must_use]
69    pub fn names() -> String {
70        Self::VARIANTS.join(", ")
71    }
72}
73
74/// How a type check renders whatever diagnostics it finds.
75#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
76pub struct TypeCheckingConfig {
77    /// Output format.
78    pub format: TypeCheckingFormat,
79    /// Whether to include ANSI colour escapes. Only `Full` and `Concise`
80    /// render any colour; the machine-readable formats ignore it.
81    pub color: bool,
82}
83
84/// Per-session type-check state: successfully committed snippets accumulate as
85/// stubs so later snippets can reference names defined by earlier ones.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct TypeCheckState {
88    /// User-provided stubs plus every snippet that has completed successfully.
89    pub committed_stubs: String,
90    /// The in-flight snippet; committed on success, discarded on error.
91    pub pending_snippet: Option<String>,
92    /// How diagnostics are rendered by whoever runs the type checker.
93    pub config: TypeCheckingConfig,
94    /// Committed imports, injected before the stubs' star import to preserve bindings `.pyi` files omit.
95    #[serde(default)]
96    pub committed_imports: String,
97    /// Imports from `pending_snippet`, appended to `committed_imports` on commit.
98    #[serde(default)]
99    pub pending_imports: String,
100    /// Modules already requested from the host, including modules without stubs, to avoid asking twice.
101    #[serde(default)]
102    pub asked_modules: Vec<String>,
103}
104
105/// A host module's `.pyi`, written as a module or package tree for the type checker.
106/// Construction validates each path component and rejects top-level names reserved by the
107/// sandbox or checker, preventing stubs from shadowing their modules.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct ModuleStub {
110    module: String,
111    source: String,
112}
113
114impl ModuleStub {
115    /// Validates each dotted component as an identifier and rejects top-level names reserved
116    /// by [`validate_module_name`] or [`CHECKER_MODULE_NAMES`].
117    pub fn new(module: impl Into<String>, source: impl Into<String>) -> Result<Self, ModuleStubError> {
118        let module = module.into();
119        let mut parts = module.split('.');
120        let top = parts.next().unwrap_or_default();
121        validate_module_name(top).map_err(|_| invalid_path(&module, top))?;
122        if CHECKER_MODULE_NAMES.contains(&top) {
123            return Err(ModuleStubError::ReservedName(top.to_owned()));
124        }
125        if let Some(part) = parts.find(|part| !is_identifier(part)) {
126            return Err(invalid_path(&module, part));
127        }
128        Ok(Self {
129            module,
130            source: source.into(),
131        })
132    }
133
134    /// The dotted module path.
135    #[must_use]
136    pub fn module(&self) -> &str {
137        &self.module
138    }
139
140    /// The `.pyi` source.
141    #[must_use]
142    pub fn source(&self) -> &str {
143        &self.source
144    }
145}
146
147/// Rejects non-identifiers and [`SANDBOX_MODULE_NAMES`], whose imports never reach the host.
148/// [`CHECKER_MODULE_NAMES`] are valid runtime module names; only [`ModuleStub::new`] rejects them.
149pub fn validate_module_name(name: &str) -> Result<(), ModuleStubError> {
150    if !is_identifier(name) {
151        Err(ModuleStubError::InvalidName(name.to_owned()))
152    } else if SANDBOX_MODULE_NAMES.contains(&name) {
153        Err(ModuleStubError::ReservedName(name.to_owned()))
154    } else {
155        Ok(())
156    }
157}
158
159/// Preserves reserved-name errors; other invalid components report the full dotted path.
160fn invalid_path(path: &str, part: &str) -> ModuleStubError {
161    if SANDBOX_MODULE_NAMES.contains(&part) {
162        ModuleStubError::ReservedName(part.to_owned())
163    } else {
164        ModuleStubError::InvalidName(path.to_owned())
165    }
166}
167
168/// An invalid or reserved host module name; see [`validate_module_name`].
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub enum ModuleStubError {
171    /// Not a Python identifier, or a keyword.
172    InvalidName(String),
173    /// A module the sandbox provides itself, or one its type stubs rely on.
174    ReservedName(String),
175}
176
177impl Display for ModuleStubError {
178    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179        match self {
180            Self::InvalidName(name) => write!(f, "module name {name:?} is not a valid identifier"),
181            Self::ReservedName(name) => write!(
182                f,
183                "module {name:?} is provided by the sandbox or its type checker and cannot be replaced"
184            ),
185        }
186    }
187}
188
189impl Error for ModuleStubError {}
190
191/// Sandbox module names, excluding the dotted name `os.path`.
192/// Imports of these modules never reach the host, so host modules cannot use these names.
193/// Keep in sync with the runtime's `StandardLib`.
194pub const SANDBOX_MODULE_NAMES: &[&str] = &[
195    "asyncio",
196    "base64",
197    "binascii",
198    "bisect",
199    "collections",
200    "copy",
201    "csv",
202    "dataclasses",
203    "datetime",
204    "decimal",
205    "functools",
206    "gc",
207    "hashlib",
208    "itertools",
209    "json",
210    "math",
211    "os",
212    "pathlib",
213    "posixpath",
214    "random",
215    "re",
216    "sys",
217    "time",
218    "tomllib",
219    "typing",
220    "unicodedata",
221];
222
223/// Vendored typeshed modules outside [`SANDBOX_MODULE_NAMES`], including private dependencies,
224/// plus ty's `ty_extensions`. Hosts may provide these modules at runtime, but
225/// [`ModuleStub::new`] rejects their stubs to prevent shadowing the checker's definitions.
226pub const CHECKER_MODULE_NAMES: &[&str] = &[
227    "__future__",
228    "__main__",
229    "_collections_abc",
230    "_typeshed",
231    "abc",
232    "builtins",
233    "enum",
234    "genericpath",
235    "ty_extensions",
236    "types",
237    "typing_extensions",
238];