Skip to content

Commit 7391f74

Browse files
Add hidden --extension to override inference of source type from file extension (#8373)
## Summary This PR addresses the incompatibility with `jupyterlab-lsp` + `python-lsp-ruff` arising from the inference of source type from file extension, raised in #6847. In particular it follows the suggestion in #6847 (comment) to specify a mapping from file extension to source type. The source types are - python - pyi - ipynb Usage: ```sh ruff check --no-cache --stdin-filename Untitled.ipynb --extension ipynb:python ``` Unlike the original suggestion, `:` instead of `=` is used to associate file extensions to language since that is what is used with `--per-file-ignores` which is an existing option that accepts a mapping. ## Test Plan 2 tests added to `integration_test.rs` to ensure the override works as expected --------- Co-authored-by: Charlie Marsh <[email protected]>
1 parent 71e93a9 commit 7391f74

File tree

7 files changed

+296
-34
lines changed

7 files changed

+296
-34
lines changed

crates/ruff_cli/src/args.rs

+10-2
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ use ruff_linter::line_width::LineLength;
88
use ruff_linter::logging::LogLevel;
99
use ruff_linter::registry::Rule;
1010
use ruff_linter::settings::types::{
11-
FilePattern, PatternPrefixPair, PerFileIgnore, PreviewMode, PythonVersion, SerializationFormat,
12-
UnsafeFixes,
11+
ExtensionPair, FilePattern, PatternPrefixPair, PerFileIgnore, PreviewMode, PythonVersion,
12+
SerializationFormat, UnsafeFixes,
1313
};
1414
use ruff_linter::{RuleParser, RuleSelector, RuleSelectorParser};
1515
use ruff_workspace::configuration::{Configuration, RuleSelection};
@@ -351,6 +351,9 @@ pub struct CheckCommand {
351351
conflicts_with = "watch",
352352
)]
353353
pub show_settings: bool,
354+
/// List of mappings from file extension to language (one of ["python", "ipynb", "pyi"]).
355+
#[arg(long, value_delimiter = ',', hide = true)]
356+
pub extension: Option<Vec<ExtensionPair>>,
354357
/// Dev-only argument to show fixes
355358
#[arg(long, hide = true)]
356359
pub ecosystem_ci: bool,
@@ -535,6 +538,7 @@ impl CheckCommand {
535538
force_exclude: resolve_bool_arg(self.force_exclude, self.no_force_exclude),
536539
output_format: self.output_format,
537540
show_fixes: resolve_bool_arg(self.show_fixes, self.no_show_fixes),
541+
extension: self.extension,
538542
},
539543
)
540544
}
@@ -647,6 +651,7 @@ pub struct CliOverrides {
647651
pub force_exclude: Option<bool>,
648652
pub output_format: Option<SerializationFormat>,
649653
pub show_fixes: Option<bool>,
654+
pub extension: Option<Vec<ExtensionPair>>,
650655
}
651656

652657
impl ConfigurationTransformer for CliOverrides {
@@ -731,6 +736,9 @@ impl ConfigurationTransformer for CliOverrides {
731736
if let Some(target_version) = &self.target_version {
732737
config.target_version = Some(*target_version);
733738
}
739+
if let Some(extension) = &self.extension {
740+
config.lint.extension = Some(extension.clone().into_iter().collect());
741+
}
734742

735743
config
736744
}

crates/ruff_cli/src/commands/check.rs

+2
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ use crate::diagnostics::Diagnostics;
3030
use crate::panic::catch_unwind;
3131

3232
/// Run the linter over a collection of files.
33+
#[allow(clippy::too_many_arguments)]
3334
pub(crate) fn check(
3435
files: &[PathBuf],
3536
pyproject_config: &PyprojectConfig,
@@ -184,6 +185,7 @@ pub(crate) fn check(
184185

185186
/// Wraps [`lint_path`](crate::diagnostics::lint_path) in a [`catch_unwind`](std::panic::catch_unwind) and emits
186187
/// a diagnostic if the linting the file panics.
188+
#[allow(clippy::too_many_arguments)]
187189
fn lint_path(
188190
path: &Path,
189191
package: Option<&Path>,

crates/ruff_cli/src/diagnostics.rs

+44-28
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@ use ruff_linter::logging::DisplayParseError;
1717
use ruff_linter::message::Message;
1818
use ruff_linter::pyproject_toml::lint_pyproject_toml;
1919
use ruff_linter::registry::AsRule;
20-
use ruff_linter::settings::types::UnsafeFixes;
20+
use ruff_linter::settings::types::{ExtensionMapping, UnsafeFixes};
2121
use ruff_linter::settings::{flags, LinterSettings};
2222
use ruff_linter::source_kind::{SourceError, SourceKind};
2323
use ruff_linter::{fs, IOError, SyntaxError};
2424
use ruff_notebook::{Notebook, NotebookError, NotebookIndex};
2525
use ruff_python_ast::imports::ImportMap;
26-
use ruff_python_ast::{SourceType, TomlSourceType};
26+
use ruff_python_ast::{PySourceType, SourceType, TomlSourceType};
2727
use ruff_source_file::{LineIndex, SourceCode, SourceFileBuilder};
2828
use ruff_text_size::{TextRange, TextSize};
2929
use ruff_workspace::Settings;
@@ -177,6 +177,11 @@ impl AddAssign for FixMap {
177177
}
178178
}
179179

180+
fn override_source_type(path: Option<&Path>, extension: &ExtensionMapping) -> Option<PySourceType> {
181+
let ext = path?.extension()?.to_str()?;
182+
extension.get(ext).map(PySourceType::from)
183+
}
184+
180185
/// Lint the source code at the given `Path`.
181186
pub(crate) fn lint_path(
182187
path: &Path,
@@ -221,31 +226,35 @@ pub(crate) fn lint_path(
221226

222227
debug!("Checking: {}", path.display());
223228

224-
let source_type = match SourceType::from(path) {
225-
SourceType::Toml(TomlSourceType::Pyproject) => {
226-
let messages = if settings
227-
.rules
228-
.iter_enabled()
229-
.any(|rule_code| rule_code.lint_source().is_pyproject_toml())
230-
{
231-
let contents = match std::fs::read_to_string(path).map_err(SourceError::from) {
232-
Ok(contents) => contents,
233-
Err(err) => {
234-
return Ok(Diagnostics::from_source_error(&err, Some(path), settings));
235-
}
229+
let source_type = match override_source_type(Some(path), &settings.extension) {
230+
Some(source_type) => source_type,
231+
None => match SourceType::from(path) {
232+
SourceType::Toml(TomlSourceType::Pyproject) => {
233+
let messages = if settings
234+
.rules
235+
.iter_enabled()
236+
.any(|rule_code| rule_code.lint_source().is_pyproject_toml())
237+
{
238+
let contents = match std::fs::read_to_string(path).map_err(SourceError::from) {
239+
Ok(contents) => contents,
240+
Err(err) => {
241+
return Ok(Diagnostics::from_source_error(&err, Some(path), settings));
242+
}
243+
};
244+
let source_file =
245+
SourceFileBuilder::new(path.to_string_lossy(), contents).finish();
246+
lint_pyproject_toml(source_file, settings)
247+
} else {
248+
vec![]
236249
};
237-
let source_file = SourceFileBuilder::new(path.to_string_lossy(), contents).finish();
238-
lint_pyproject_toml(source_file, settings)
239-
} else {
240-
vec![]
241-
};
242-
return Ok(Diagnostics {
243-
messages,
244-
..Diagnostics::default()
245-
});
246-
}
247-
SourceType::Toml(_) => return Ok(Diagnostics::default()),
248-
SourceType::Python(source_type) => source_type,
250+
return Ok(Diagnostics {
251+
messages,
252+
..Diagnostics::default()
253+
});
254+
}
255+
SourceType::Toml(_) => return Ok(Diagnostics::default()),
256+
SourceType::Python(source_type) => source_type,
257+
},
249258
};
250259

251260
// Extract the sources from the file.
@@ -370,8 +379,15 @@ pub(crate) fn lint_stdin(
370379
fix_mode: flags::FixMode,
371380
) -> Result<Diagnostics> {
372381
// TODO(charlie): Support `pyproject.toml`.
373-
let SourceType::Python(source_type) = path.map(SourceType::from).unwrap_or_default() else {
374-
return Ok(Diagnostics::default());
382+
let source_type = if let Some(source_type) =
383+
override_source_type(path, &settings.linter.extension)
384+
{
385+
source_type
386+
} else {
387+
let SourceType::Python(source_type) = path.map(SourceType::from).unwrap_or_default() else {
388+
return Ok(Diagnostics::default());
389+
};
390+
source_type
375391
};
376392

377393
// Extract the sources from the file.

crates/ruff_cli/tests/integration_test.rs

+113
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,119 @@ fn stdin_fix_jupyter() {
320320
Found 2 errors (2 fixed, 0 remaining).
321321
"###);
322322
}
323+
#[test]
324+
fn stdin_override_parser_ipynb() {
325+
let args = ["--extension", "py:ipynb", "--stdin-filename", "Jupyter.py"];
326+
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
327+
.args(STDIN_BASE_OPTIONS)
328+
.args(args)
329+
.pass_stdin(r#"{
330+
"cells": [
331+
{
332+
"cell_type": "code",
333+
"execution_count": 1,
334+
"id": "dccc687c-96e2-4604-b957-a8a89b5bec06",
335+
"metadata": {},
336+
"outputs": [],
337+
"source": [
338+
"import os"
339+
]
340+
},
341+
{
342+
"cell_type": "markdown",
343+
"id": "19e1b029-f516-4662-a9b9-623b93edac1a",
344+
"metadata": {},
345+
"source": [
346+
"Foo"
347+
]
348+
},
349+
{
350+
"cell_type": "code",
351+
"execution_count": 2,
352+
"id": "cdce7b92-b0fb-4c02-86f6-e233b26fa84f",
353+
"metadata": {},
354+
"outputs": [],
355+
"source": [
356+
"import sys"
357+
]
358+
},
359+
{
360+
"cell_type": "code",
361+
"execution_count": 3,
362+
"id": "e40b33d2-7fe4-46c5-bdf0-8802f3052565",
363+
"metadata": {},
364+
"outputs": [
365+
{
366+
"name": "stdout",
367+
"output_type": "stream",
368+
"text": [
369+
"1\n"
370+
]
371+
}
372+
],
373+
"source": [
374+
"print(1)"
375+
]
376+
},
377+
{
378+
"cell_type": "code",
379+
"execution_count": null,
380+
"id": "a1899bc8-d46f-4ec0-b1d1-e1ca0f04bf60",
381+
"metadata": {},
382+
"outputs": [],
383+
"source": []
384+
}
385+
],
386+
"metadata": {
387+
"kernelspec": {
388+
"display_name": "Python 3 (ipykernel)",
389+
"language": "python",
390+
"name": "python3"
391+
},
392+
"language_info": {
393+
"codemirror_mode": {
394+
"name": "ipython",
395+
"version": 3
396+
},
397+
"file_extension": ".py",
398+
"mimetype": "text/x-python",
399+
"name": "python",
400+
"nbconvert_exporter": "python",
401+
"pygments_lexer": "ipython3",
402+
"version": "3.11.2"
403+
}
404+
},
405+
"nbformat": 4,
406+
"nbformat_minor": 5
407+
}"#), @r###"
408+
success: false
409+
exit_code: 1
410+
----- stdout -----
411+
Jupyter.py:cell 1:1:8: F401 [*] `os` imported but unused
412+
Jupyter.py:cell 3:1:8: F401 [*] `sys` imported but unused
413+
Found 2 errors.
414+
[*] 2 fixable with the `--fix` option.
415+
416+
----- stderr -----
417+
"###);
418+
}
419+
420+
#[test]
421+
fn stdin_override_parser_py() {
422+
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
423+
.args(STDIN_BASE_OPTIONS)
424+
.args(["--extension", "ipynb:python", "--stdin-filename", "F401.ipynb"])
425+
.pass_stdin("import os\n"), @r###"
426+
success: false
427+
exit_code: 1
428+
----- stdout -----
429+
F401.ipynb:1:8: F401 [*] `os` imported but unused
430+
Found 1 error.
431+
[*] 1 fixable with the `--fix` option.
432+
433+
----- stderr -----
434+
"###);
435+
}
323436

324437
#[test]
325438
fn stdin_fix_when_not_fixable_should_still_print_contents() {

crates/ruff_linter/src/settings/mod.rs

+3-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ use crate::rules::{
2323
flake8_tidy_imports, flake8_type_checking, flake8_unused_arguments, isort, mccabe, pep8_naming,
2424
pycodestyle, pydocstyle, pyflakes, pylint, pyupgrade,
2525
};
26-
use crate::settings::types::{FilePatternSet, PerFileIgnore, PythonVersion};
26+
use crate::settings::types::{ExtensionMapping, FilePatternSet, PerFileIgnore, PythonVersion};
2727
use crate::{codes, RuleSelector};
2828

2929
use super::line_width::IndentWidth;
@@ -50,6 +50,7 @@ pub struct LinterSettings {
5050
pub target_version: PythonVersion,
5151
pub preview: PreviewMode,
5252
pub explicit_preview_rules: bool,
53+
pub extension: ExtensionMapping,
5354

5455
// Rule-specific settings
5556
pub allowed_confusables: FxHashSet<char>,
@@ -187,6 +188,7 @@ impl LinterSettings {
187188
pyupgrade: pyupgrade::settings::Settings::default(),
188189
preview: PreviewMode::default(),
189190
explicit_preview_rules: false,
191+
extension: ExtensionMapping::default(),
190192
}
191193
}
192194

0 commit comments

Comments
 (0)