Skip to content

Commit bf4389d

Browse files
committed
Avoid introducing syntax errors for FAST003 autofix
1 parent 0115627 commit bf4389d

4 files changed

Lines changed: 215 additions & 28 deletions

File tree

crates/ruff_linter/resources/test/fixtures/fastapi/FAST003.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,3 +265,32 @@ async def get_import():
265265
@app.get("/debug/{__debug__}")
266266
async def get_debug():
267267
...
268+
269+
270+
# https://github.com/astral-sh/ruff/issues/19831
271+
272+
# Errors: vararg-only and kwarg-only functions
273+
@app.get("/things/{thing_id}")
274+
async def read_thing_vararg(*query: str): ...
275+
276+
@app.get("/things/{thing_id}")
277+
async def read_thing_kwarg(**query: str): ...
278+
279+
@app.get("/things/{thing_id}")
280+
async def read_thing_vararg_kwarg(*args, **kwargs): ...
281+
282+
# Errors: positional-only parameter edge cases
283+
@app.get("/things/{thing_id}")
284+
async def read_thing_posonly(query: str, /): ...
285+
286+
@app.get("/things/{thing_id}")
287+
async def read_thing_posonly_trailing(query: str, /,): ...
288+
289+
@app.get("/things/{thing_id}")
290+
async def read_thing_posonly_default(query: str = "", /): ...
291+
292+
@app.get("/things/{thing_id}")
293+
async def read_thing_posonly_default_trailing(query: str = "", /,): ...
294+
295+
@app.get("/things/{thing_id}")
296+
async def read_thing_posonly_with_regular(query: str = "", /, x=None): ...

crates/ruff_linter/src/fix/edits.rs

Lines changed: 51 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -325,50 +325,75 @@ pub(crate) fn remove_member(elts: &[ast::Expr], index: usize, source: &str) -> R
325325
}
326326

327327
/// Generic function to add a (regular) parameter to a function definition.
328-
pub(crate) fn add_parameter(parameter: &str, parameters: &Parameters, source: &str) -> Edit {
328+
///
329+
/// Returns `None` if the parameter cannot be added without introducing a syntax error (e.g., a
330+
/// non-default parameter would follow a positional-only parameter with a default value).
331+
pub(crate) fn add_parameter(
332+
parameter: &str,
333+
parameters: &Parameters,
334+
source: &str,
335+
) -> Option<Edit> {
329336
if let Some(last) = parameters.args.iter().rfind(|arg| arg.default.is_none()) {
330-
// Case 1: at least one regular parameter, so append after the last one.
331-
Edit::insertion(format!(", {parameter}"), last.end())
332-
} else if !parameters.args.is_empty() {
333-
// Case 2: no regular parameters, but at least one keyword parameter, so add before the
334-
// first.
335-
let pos = parameters.start();
336-
let mut tokenizer = SimpleTokenizer::starts_at(pos, source);
337-
let name = tokenizer
338-
.find(|token| token.kind == SimpleTokenKind::Name)
339-
.expect("Unable to find name token");
340-
Edit::insertion(format!("{parameter}, "), name.start())
337+
// Case 1: at least one regular parameter without a default, so append after the last one.
338+
Some(Edit::insertion(format!(", {parameter}"), last.end()))
339+
} else if let Some(first) = parameters.args.first() {
340+
// Case 2: all regular parameters have defaults, so insert before the first one.
341+
// However, if any positional-only parameter has a default, inserting a non-default
342+
// parameter here would be a syntax error (the "default required" constraint carries
343+
// through the `/` separator).
344+
if parameters
345+
.posonlyargs
346+
.last()
347+
.is_some_and(|p| p.default.is_some())
348+
{
349+
return None;
350+
}
351+
Some(Edit::insertion(format!("{parameter}, "), first.start()))
341352
} else if let Some(last) = parameters.posonlyargs.last() {
342-
// Case 2: no regular parameter, but a positional-only parameter exists, so add after that.
343-
// We take care to add it *after* the `/` separator.
344-
let pos = last.end();
345-
let mut tokenizer = SimpleTokenizer::starts_at(pos, source);
353+
// Case 3: no regular parameters, but positional-only parameters exist.
354+
// If any positional-only parameter has a default, we can't add a non-default parameter
355+
// after the `/` separator — that would be a syntax error.
356+
if last.default.is_some() {
357+
return None;
358+
}
359+
// Insert after the `/` separator.
360+
let mut tokenizer = SimpleTokenizer::starts_at(last.end(), source);
346361
let slash = tokenizer
347362
.find(|token| token.kind == SimpleTokenKind::Slash)
348363
.expect("Unable to find `/` token");
349364
// Try to find a comma after the slash.
350365
let comma = tokenizer.find(|token| token.kind == SimpleTokenKind::Comma);
351366
if let Some(comma) = comma {
352-
Edit::insertion(format!(" {parameter},"), comma.start() + TextSize::from(1))
367+
Some(Edit::insertion(format!(" {parameter},"), comma.end()))
353368
} else {
354-
Edit::insertion(format!(", {parameter}"), slash.start())
369+
Some(Edit::insertion(format!(", {parameter}"), slash.end()))
355370
}
356-
} else if !parameters.kwonlyargs.is_empty() {
357-
// Case 3: no regular parameter, but a keyword-only parameter exist, so add parameter before that.
358-
// We need to backtrack to before the `*` separator.
359-
// We know there is no non-keyword-only params, so we can safely assume that the `*` separator is the first
371+
} else if parameters.vararg.is_some() || !parameters.kwonlyargs.is_empty() {
372+
// Case 4: no regular or positional-only parameters, but a vararg (`*args`) or
373+
// keyword-only parameters exist. Insert before the `*` separator.
360374
let pos = parameters.start();
361375
let mut tokenizer = SimpleTokenizer::starts_at(pos, source);
362376
let star = tokenizer
363377
.find(|token| token.kind == SimpleTokenKind::Star)
364378
.expect("Unable to find `*` token");
365-
Edit::insertion(format!("{parameter}, "), star.start())
379+
Some(Edit::insertion(format!("{parameter}, "), star.start()))
380+
} else if parameters.kwarg.is_some() {
381+
// Case 5: only a `**kwargs` parameter exists. Insert before `**`.
382+
let pos = parameters.start();
383+
let mut tokenizer = SimpleTokenizer::starts_at(pos, source);
384+
let double_star = tokenizer
385+
.find(|token| token.kind == SimpleTokenKind::DoubleStar)
386+
.expect("Unable to find `**` token");
387+
Some(Edit::insertion(
388+
format!("{parameter}, "),
389+
double_star.start(),
390+
))
366391
} else {
367-
// Case 4: no parameters at all, so add parameter after the opening parenthesis.
368-
Edit::insertion(
392+
// Case 6: no parameters at all, so add parameter after the opening parenthesis.
393+
Some(Edit::insertion(
369394
parameter.to_string(),
370395
parameters.start() + TextSize::from(1),
371-
)
396+
))
372397
}
373398
}
374399

crates/ruff_linter/src/rules/fastapi/rules/fastapi_unused_path_parameter.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,13 @@ pub(crate) fn fastapi_unused_path_parameter(
194194
.sub_end(TextSize::from((path.len() - range.end + 1) as u32)),
195195
);
196196
if !is_positional && is_identifier(path_param) && path_param != "__debug__" {
197-
diagnostic.set_fix(Fix::unsafe_edit(add_parameter(
197+
if let Some(edit) = add_parameter(
198198
path_param,
199199
&function_def.parameters,
200200
checker.locator().contents(),
201-
)));
201+
) {
202+
diagnostic.set_fix(Fix::unsafe_edit(edit));
203+
}
202204
}
203205
}
204206
}

crates/ruff_linter/src/rules/fastapi/snapshots/ruff_linter__rules__fastapi__tests__fast-api-unused-path-parameter_FAST003.py.snap

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,3 +386,134 @@ FAST003 Parameter `__debug__` appears in route path, but not in `get_debug` sign
386386
267 | ...
387387
|
388388
help: Add `__debug__` to function signature
389+
390+
FAST003 [*] Parameter `thing_id` appears in route path, but not in `read_thing_vararg` signature
391+
--> FAST003.py:273:19
392+
|
393+
272 | # Errors: vararg-only and kwarg-only functions
394+
273 | @app.get("/things/{thing_id}")
395+
| ^^^^^^^^^^
396+
274 | async def read_thing_vararg(*query: str): ...
397+
|
398+
help: Add `thing_id` to function signature
399+
271 |
400+
272 | # Errors: vararg-only and kwarg-only functions
401+
273 | @app.get("/things/{thing_id}")
402+
- async def read_thing_vararg(*query: str): ...
403+
274 + async def read_thing_vararg(thing_id, *query: str): ...
404+
275 |
405+
276 | @app.get("/things/{thing_id}")
406+
277 | async def read_thing_kwarg(**query: str): ...
407+
note: This is an unsafe fix and may change runtime behavior
408+
409+
FAST003 [*] Parameter `thing_id` appears in route path, but not in `read_thing_kwarg` signature
410+
--> FAST003.py:276:19
411+
|
412+
274 | async def read_thing_vararg(*query: str): ...
413+
275 |
414+
276 | @app.get("/things/{thing_id}")
415+
| ^^^^^^^^^^
416+
277 | async def read_thing_kwarg(**query: str): ...
417+
|
418+
help: Add `thing_id` to function signature
419+
274 | async def read_thing_vararg(*query: str): ...
420+
275 |
421+
276 | @app.get("/things/{thing_id}")
422+
- async def read_thing_kwarg(**query: str): ...
423+
277 + async def read_thing_kwarg(thing_id, **query: str): ...
424+
278 |
425+
279 | @app.get("/things/{thing_id}")
426+
280 | async def read_thing_vararg_kwarg(*args, **kwargs): ...
427+
note: This is an unsafe fix and may change runtime behavior
428+
429+
FAST003 [*] Parameter `thing_id` appears in route path, but not in `read_thing_vararg_kwarg` signature
430+
--> FAST003.py:279:19
431+
|
432+
277 | async def read_thing_kwarg(**query: str): ...
433+
278 |
434+
279 | @app.get("/things/{thing_id}")
435+
| ^^^^^^^^^^
436+
280 | async def read_thing_vararg_kwarg(*args, **kwargs): ...
437+
|
438+
help: Add `thing_id` to function signature
439+
277 | async def read_thing_kwarg(**query: str): ...
440+
278 |
441+
279 | @app.get("/things/{thing_id}")
442+
- async def read_thing_vararg_kwarg(*args, **kwargs): ...
443+
280 + async def read_thing_vararg_kwarg(thing_id, *args, **kwargs): ...
444+
281 |
445+
282 | # Errors: positional-only parameter edge cases
446+
283 | @app.get("/things/{thing_id}")
447+
note: This is an unsafe fix and may change runtime behavior
448+
449+
FAST003 [*] Parameter `thing_id` appears in route path, but not in `read_thing_posonly` signature
450+
--> FAST003.py:283:19
451+
|
452+
282 | # Errors: positional-only parameter edge cases
453+
283 | @app.get("/things/{thing_id}")
454+
| ^^^^^^^^^^
455+
284 | async def read_thing_posonly(query: str, /): ...
456+
|
457+
help: Add `thing_id` to function signature
458+
281 |
459+
282 | # Errors: positional-only parameter edge cases
460+
283 | @app.get("/things/{thing_id}")
461+
- async def read_thing_posonly(query: str, /): ...
462+
284 + async def read_thing_posonly(query: str, /, thing_id): ...
463+
285 |
464+
286 | @app.get("/things/{thing_id}")
465+
287 | async def read_thing_posonly_trailing(query: str, /,): ...
466+
note: This is an unsafe fix and may change runtime behavior
467+
468+
FAST003 [*] Parameter `thing_id` appears in route path, but not in `read_thing_posonly_trailing` signature
469+
--> FAST003.py:286:19
470+
|
471+
284 | async def read_thing_posonly(query: str, /): ...
472+
285 |
473+
286 | @app.get("/things/{thing_id}")
474+
| ^^^^^^^^^^
475+
287 | async def read_thing_posonly_trailing(query: str, /,): ...
476+
|
477+
help: Add `thing_id` to function signature
478+
284 | async def read_thing_posonly(query: str, /): ...
479+
285 |
480+
286 | @app.get("/things/{thing_id}")
481+
- async def read_thing_posonly_trailing(query: str, /,): ...
482+
287 + async def read_thing_posonly_trailing(query: str, /, thing_id,): ...
483+
288 |
484+
289 | @app.get("/things/{thing_id}")
485+
290 | async def read_thing_posonly_default(query: str = "", /): ...
486+
note: This is an unsafe fix and may change runtime behavior
487+
488+
FAST003 Parameter `thing_id` appears in route path, but not in `read_thing_posonly_default` signature
489+
--> FAST003.py:289:19
490+
|
491+
287 | async def read_thing_posonly_trailing(query: str, /,): ...
492+
288 |
493+
289 | @app.get("/things/{thing_id}")
494+
| ^^^^^^^^^^
495+
290 | async def read_thing_posonly_default(query: str = "", /): ...
496+
|
497+
help: Add `thing_id` to function signature
498+
499+
FAST003 Parameter `thing_id` appears in route path, but not in `read_thing_posonly_default_trailing` signature
500+
--> FAST003.py:292:19
501+
|
502+
290 | async def read_thing_posonly_default(query: str = "", /): ...
503+
291 |
504+
292 | @app.get("/things/{thing_id}")
505+
| ^^^^^^^^^^
506+
293 | async def read_thing_posonly_default_trailing(query: str = "", /,): ...
507+
|
508+
help: Add `thing_id` to function signature
509+
510+
FAST003 Parameter `thing_id` appears in route path, but not in `read_thing_posonly_with_regular` signature
511+
--> FAST003.py:295:19
512+
|
513+
293 | async def read_thing_posonly_default_trailing(query: str = "", /,): ...
514+
294 |
515+
295 | @app.get("/things/{thing_id}")
516+
| ^^^^^^^^^^
517+
296 | async def read_thing_posonly_with_regular(query: str = "", /, x=None): ...
518+
|
519+
help: Add `thing_id` to function signature

0 commit comments

Comments
 (0)