Skip to content

Commit 6d3bf51

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

4 files changed

Lines changed: 231 additions & 39 deletions

File tree

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,11 @@ async def f4():
257257
async def f5():
258258
return locals()
259259

260+
# https://github.com/astral-sh/ruff/issues/19831
261+
# NFKC-equivalent non-ASCII path parameter should not match (would cause infinite loop)
262+
@app.get("/queries/{𝑞𝑢𝑒𝑟𝑦}")
263+
async def read_query_nfkc(query: str): ...
264+
260265
# https://github.com/astral-sh/ruff/issues/20941
261266
@app.get("/imports/{import}")
262267
async def get_import():
@@ -265,3 +270,32 @@ async def get_import():
265270
@app.get("/debug/{__debug__}")
266271
async def get_debug():
267272
...
273+
274+
275+
# https://github.com/astral-sh/ruff/issues/19831
276+
277+
# Errors: vararg-only and kwarg-only functions
278+
@app.get("/things/{thing_id}")
279+
async def read_thing_vararg(*query: str): ...
280+
281+
@app.get("/things/{thing_id}")
282+
async def read_thing_kwarg(**query: str): ...
283+
284+
@app.get("/things/{thing_id}")
285+
async def read_thing_vararg_kwarg(*args, **kwargs): ...
286+
287+
# Errors: positional-only parameter edge cases
288+
@app.get("/things/{thing_id}")
289+
async def read_thing_posonly(query: str, /): ...
290+
291+
@app.get("/things/{thing_id}")
292+
async def read_thing_posonly_trailing(query: str, /,): ...
293+
294+
@app.get("/things/{thing_id}")
295+
async def read_thing_posonly_default(query: str = "", /): ...
296+
297+
@app.get("/things/{thing_id}")
298+
async def read_thing_posonly_default_trailing(query: str = "", /,): ...
299+
300+
@app.get("/things/{thing_id}")
301+
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: 142 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -365,24 +365,155 @@ help: Add `id` to function signature
365365
note: This is an unsafe fix and may change runtime behavior
366366

367367
FAST003 Parameter `import` appears in route path, but not in `get_import` signature
368-
--> FAST003.py:261:20
368+
--> FAST003.py:266:20
369369
|
370-
260 | # https://github.com/astral-sh/ruff/issues/20941
371-
261 | @app.get("/imports/{import}")
370+
265 | # https://github.com/astral-sh/ruff/issues/20941
371+
266 | @app.get("/imports/{import}")
372372
| ^^^^^^^^
373-
262 | async def get_import():
374-
263 | ...
373+
267 | async def get_import():
374+
268 | ...
375375
|
376376
help: Add `import` to function signature
377377

378378
FAST003 Parameter `__debug__` appears in route path, but not in `get_debug` signature
379-
--> FAST003.py:265:18
379+
--> FAST003.py:270:18
380380
|
381-
263 | ...
382-
264 |
383-
265 | @app.get("/debug/{__debug__}")
381+
268 | ...
382+
269 |
383+
270 | @app.get("/debug/{__debug__}")
384384
| ^^^^^^^^^^^
385-
266 | async def get_debug():
386-
267 | ...
385+
271 | async def get_debug():
386+
272 | ...
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:278:19
392+
|
393+
277 | # Errors: vararg-only and kwarg-only functions
394+
278 | @app.get("/things/{thing_id}")
395+
| ^^^^^^^^^^
396+
279 | async def read_thing_vararg(*query: str): ...
397+
|
398+
help: Add `thing_id` to function signature
399+
276 |
400+
277 | # Errors: vararg-only and kwarg-only functions
401+
278 | @app.get("/things/{thing_id}")
402+
- async def read_thing_vararg(*query: str): ...
403+
279 + async def read_thing_vararg(thing_id, *query: str): ...
404+
280 |
405+
281 | @app.get("/things/{thing_id}")
406+
282 | 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:281:19
411+
|
412+
279 | async def read_thing_vararg(*query: str): ...
413+
280 |
414+
281 | @app.get("/things/{thing_id}")
415+
| ^^^^^^^^^^
416+
282 | async def read_thing_kwarg(**query: str): ...
417+
|
418+
help: Add `thing_id` to function signature
419+
279 | async def read_thing_vararg(*query: str): ...
420+
280 |
421+
281 | @app.get("/things/{thing_id}")
422+
- async def read_thing_kwarg(**query: str): ...
423+
282 + async def read_thing_kwarg(thing_id, **query: str): ...
424+
283 |
425+
284 | @app.get("/things/{thing_id}")
426+
285 | 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:284:19
431+
|
432+
282 | async def read_thing_kwarg(**query: str): ...
433+
283 |
434+
284 | @app.get("/things/{thing_id}")
435+
| ^^^^^^^^^^
436+
285 | async def read_thing_vararg_kwarg(*args, **kwargs): ...
437+
|
438+
help: Add `thing_id` to function signature
439+
282 | async def read_thing_kwarg(**query: str): ...
440+
283 |
441+
284 | @app.get("/things/{thing_id}")
442+
- async def read_thing_vararg_kwarg(*args, **kwargs): ...
443+
285 + async def read_thing_vararg_kwarg(thing_id, *args, **kwargs): ...
444+
286 |
445+
287 | # Errors: positional-only parameter edge cases
446+
288 | @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:288:19
451+
|
452+
287 | # Errors: positional-only parameter edge cases
453+
288 | @app.get("/things/{thing_id}")
454+
| ^^^^^^^^^^
455+
289 | async def read_thing_posonly(query: str, /): ...
456+
|
457+
help: Add `thing_id` to function signature
458+
286 |
459+
287 | # Errors: positional-only parameter edge cases
460+
288 | @app.get("/things/{thing_id}")
461+
- async def read_thing_posonly(query: str, /): ...
462+
289 + async def read_thing_posonly(query: str, /, thing_id): ...
463+
290 |
464+
291 | @app.get("/things/{thing_id}")
465+
292 | 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:291:19
470+
|
471+
289 | async def read_thing_posonly(query: str, /): ...
472+
290 |
473+
291 | @app.get("/things/{thing_id}")
474+
| ^^^^^^^^^^
475+
292 | async def read_thing_posonly_trailing(query: str, /,): ...
476+
|
477+
help: Add `thing_id` to function signature
478+
289 | async def read_thing_posonly(query: str, /): ...
479+
290 |
480+
291 | @app.get("/things/{thing_id}")
481+
- async def read_thing_posonly_trailing(query: str, /,): ...
482+
292 + async def read_thing_posonly_trailing(query: str, /, thing_id,): ...
483+
293 |
484+
294 | @app.get("/things/{thing_id}")
485+
295 | 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:294:19
490+
|
491+
292 | async def read_thing_posonly_trailing(query: str, /,): ...
492+
293 |
493+
294 | @app.get("/things/{thing_id}")
494+
| ^^^^^^^^^^
495+
295 | 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:297:19
501+
|
502+
295 | async def read_thing_posonly_default(query: str = "", /): ...
503+
296 |
504+
297 | @app.get("/things/{thing_id}")
505+
| ^^^^^^^^^^
506+
298 | 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:300:19
512+
|
513+
298 | async def read_thing_posonly_default_trailing(query: str = "", /,): ...
514+
299 |
515+
300 | @app.get("/things/{thing_id}")
516+
| ^^^^^^^^^^
517+
301 | 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)