diff --git a/crates/djls-ide/src/completions.rs b/crates/djls-ide/src/completions.rs index 8c33cf66..d923fcf6 100644 --- a/crates/djls-ide/src/completions.rs +++ b/crates/djls-ide/src/completions.rs @@ -579,7 +579,7 @@ fn generate_argument_completions( } } } - TagArg::Var { name, .. } => { + TagArg::Variable { name, .. } => { // For variables, we could offer variable completions from context // For now, just provide a hint if partial.is_empty() { diff --git a/crates/djls-ide/src/snippets.rs b/crates/djls-ide/src/snippets.rs index 5662bde6..f8afd735 100644 --- a/crates/djls-ide/src/snippets.rs +++ b/crates/djls-ide/src/snippets.rs @@ -25,7 +25,7 @@ pub fn generate_snippet_from_args(args: &[TagArg]) -> String { // At this point, we know it's required (optional literals were skipped above) lit.to_string() } - TagArg::Var { name, .. } | TagArg::Expr { name, .. } => { + TagArg::Variable { name, .. } | TagArg::Any { name, .. } => { // Variables and expressions become placeholders let result = format!("${{{}:{}}}", placeholder_index, name.as_ref()); placeholder_index += 1; @@ -125,22 +125,10 @@ mod tests { #[test] fn test_snippet_for_for_tag() { let args = vec![ - TagArg::Var { - name: "item".into(), - required: true, - }, - TagArg::Literal { - lit: "in".into(), - required: true, - }, - TagArg::Var { - name: "items".into(), - required: true, - }, - TagArg::Literal { - lit: "reversed".into(), - required: false, - }, + TagArg::var("item", true), + TagArg::syntax("in", true), + TagArg::var("items", true), + TagArg::modifier("reversed", false), ]; let snippet = generate_snippet_from_args(&args); @@ -149,10 +137,7 @@ mod tests { #[test] fn test_snippet_for_if_tag() { - let args = vec![TagArg::Expr { - name: "condition".into(), - required: true, - }]; + let args = vec![TagArg::expr("condition", true)]; let snippet = generate_snippet_from_args(&args); assert_eq!(snippet, "${1:condition}"); @@ -198,17 +183,11 @@ mod tests { end_tag: Some(EndTag { name: "endblock".into(), required: true, - args: vec![TagArg::Var { - name: "name".into(), - required: false, - }] + args: vec![TagArg::var("name", false)] .into(), }), intermediate_tags: Cow::Borrowed(&[]), - args: vec![TagArg::Var { - name: "name".into(), - required: true, - }] + args: vec![TagArg::var("name", true)] .into(), }; @@ -254,14 +233,8 @@ mod tests { name: "args".into(), required: false, }, - TagArg::Literal { - lit: "as".into(), - required: false, - }, - TagArg::Var { - name: "varname".into(), - required: false, - }, + TagArg::syntax("as", false), + TagArg::var("varname", false), ]; let snippet = generate_snippet_from_args(&args); diff --git a/crates/djls-semantic/src/lib.rs b/crates/djls-semantic/src/lib.rs index 350516e1..d60c76d9 100644 --- a/crates/djls-semantic/src/lib.rs +++ b/crates/djls-semantic/src/lib.rs @@ -24,9 +24,11 @@ use semantic::validate_block_tags; use semantic::validate_non_block_tags; pub use templatetags::django_builtin_specs; pub use templatetags::EndTag; +pub use templatetags::LiteralKind; pub use templatetags::TagArg; pub use templatetags::TagSpec; pub use templatetags::TagSpecs; +pub use templatetags::TokenCount; /// Validate a Django template node list and return validation errors. /// diff --git a/crates/djls-semantic/src/semantic/args.rs b/crates/djls-semantic/src/semantic/args.rs index 668395af..cb464073 100644 --- a/crates/djls-semantic/src/semantic/args.rs +++ b/crates/djls-semantic/src/semantic/args.rs @@ -163,7 +163,8 @@ fn validate_choices_and_order( args_consumed = arg_index + 1; match arg { - TagArg::Literal { lit, required } => { + TagArg::Literal { lit, required, .. } => { + // kind field is ignored for validation - it's only for semantic hints let matches_literal = bits[bit_index] == lit.as_ref(); if *required { if matches_literal { @@ -257,6 +258,11 @@ fn validate_choices_and_order( if token.contains('=') { break; } + crate::templatetags::TokenCount::Greedy => { + // Assignment arguments can appear as: + // 1. Single token: var=value + // 2. Multi-token: expr as varname + // Consume until we find = or "as", or hit next literal // If we hit "as", consume one more token (the variable name) if token == "as" { @@ -449,16 +455,13 @@ mod tests { fn test_if_tag_with_comparison_operator() { // Issue #1: {% if message.input_tokens > 0 %} // Parser tokenizes as: ["message.input_tokens", ">", "0"] - // Spec expects: [Expr{name="condition"}] + // Spec expects: [Any{name="condition", count=Greedy}] let bits = vec![ "message.input_tokens".to_string(), ">".to_string(), "0".to_string(), ]; - let args = vec![TagArg::Expr { - name: "condition".into(), - required: true, - }]; + let args = vec![TagArg::expr("condition", true)]; let errors = check_validation_errors("if", &bits, &args); assert!( @@ -494,22 +497,10 @@ mod tests { "reversed".to_string(), ]; let args = vec![ - TagArg::Var { - name: "item".into(), - required: true, - }, - TagArg::Literal { - lit: "in".into(), - required: true, - }, - TagArg::Var { - name: "items".into(), - required: true, - }, - TagArg::Literal { - lit: "reversed".into(), - required: false, - }, + TagArg::var("item", true), + TagArg::syntax("in", true), + TagArg::var("items", true), + TagArg::modifier("reversed", false), ]; let errors = check_validation_errors("for", &bits, &args); @@ -527,10 +518,7 @@ mod tests { "and".to_string(), "user.is_staff".to_string(), ]; - let args = vec![TagArg::Expr { - name: "condition".into(), - required: true, - }]; + let args = vec![TagArg::expr("condition", true)]; let errors = check_validation_errors("if", &bits, &args); assert!( @@ -567,10 +555,7 @@ mod tests { fn test_with_assignment() { // {% with total=items|length %} let bits = vec!["total=items|length".to_string()]; - let args = vec![TagArg::Assignment { - name: "bindings".into(), - required: true, - }]; + let args = vec![TagArg::assignment("bindings", true)]; let errors = check_validation_errors("with", &bits, &args); assert!( @@ -602,14 +587,8 @@ mod tests { "reversed".to_string(), ]; let args = vec![ - TagArg::Expr { - name: "condition".into(), - required: true, - }, - TagArg::Literal { - lit: "reversed".into(), - required: false, - }, + TagArg::expr("condition", true), + TagArg::modifier("reversed", false), ]; let errors = check_validation_errors("if", &bits, &args); @@ -769,18 +748,9 @@ mod tests { "library".to_string(), ]; let args = vec![ - TagArg::VarArgs { - name: "tags".into(), - required: false, - }, - TagArg::Literal { - lit: "from".into(), - required: false, - }, - TagArg::Var { - name: "library".into(), - required: false, - }, + TagArg::varargs("tags", false), + TagArg::syntax("from", false), + TagArg::var("library", false), ]; let errors = check_validation_errors("load", &bits, &args); diff --git a/crates/djls-semantic/src/templatetags.rs b/crates/djls-semantic/src/templatetags.rs index 4443bd0d..b4b1dcf5 100644 --- a/crates/djls-semantic/src/templatetags.rs +++ b/crates/djls-semantic/src/templatetags.rs @@ -7,3 +7,4 @@ pub(crate) use specs::IntermediateTag; pub use specs::TagArg; pub use specs::TagSpec; pub use specs::TagSpecs; +pub use specs::TokenCount; diff --git a/crates/djls-semantic/src/templatetags/builtins.rs b/crates/djls-semantic/src/templatetags/builtins.rs index 3774b2bb..bd979841 100644 --- a/crates/djls-semantic/src/templatetags/builtins.rs +++ b/crates/djls-semantic/src/templatetags/builtins.rs @@ -10,9 +10,11 @@ use rustc_hash::FxHashMap; use super::specs::EndTag; use super::specs::IntermediateTag; +use super::specs::LiteralKind; use super::specs::TagArg; use super::specs::TagSpec; use super::specs::TagSpecs; +use super::specs::TokenCount; const DEFAULTTAGS_MOD: &str = "django.template.defaulttags"; static DEFAULTTAGS_PAIRS: &[(&str, &TagSpec)] = &[ @@ -72,14 +74,17 @@ static DEFAULTTAGS_PAIRS: &[(&str, &TagSpec)] = &[ TagArg::Literal { lit: B("as"), required: false, + kind: LiteralKind::Syntax, }, - TagArg::Var { + TagArg::Variable { name: B("varname"), required: false, + count: TokenCount::Exact(1), }, TagArg::Literal { lit: B("silent"), required: false, + kind: LiteralKind::Modifier, }, ]), }, @@ -127,10 +132,12 @@ static DEFAULTTAGS_PAIRS: &[(&str, &TagSpec)] = &[ TagArg::Literal { lit: B("as"), required: false, + kind: LiteralKind::Syntax, }, - TagArg::Var { + TagArg::Variable { name: B("varname"), required: false, + count: TokenCount::Exact(1), }, ]), }, @@ -149,21 +156,25 @@ static DEFAULTTAGS_PAIRS: &[(&str, &TagSpec)] = &[ args: B(&[]), }]), args: B(&[ - TagArg::Var { + TagArg::Variable { name: B("item"), required: true, + count: TokenCount::Exact(1), }, TagArg::Literal { lit: B("in"), required: true, + kind: LiteralKind::Syntax, }, - TagArg::Var { + TagArg::Variable { name: B("items"), required: true, + count: TokenCount::Exact(1), }, TagArg::Literal { lit: B("reversed"), required: false, + kind: LiteralKind::Modifier, }, ]), }, @@ -180,19 +191,21 @@ static DEFAULTTAGS_PAIRS: &[(&str, &TagSpec)] = &[ intermediate_tags: B(&[ IntermediateTag { name: B("elif"), - args: B(&[TagArg::Expr { - name: B("condition"), - required: true, - }]), + args: B(&[TagArg::Any { + name: B("condition"), + required: true, + count: TokenCount::Greedy, + }]), }, IntermediateTag { name: B("else"), args: B(&[]), }, ]), - args: B(&[TagArg::Expr { + args: B(&[TagArg::Any { name: B("condition"), required: true, + count: TokenCount::Greedy, }]), }, ), @@ -234,9 +247,10 @@ static DEFAULTTAGS_PAIRS: &[(&str, &TagSpec)] = &[ end_tag: None, intermediate_tags: B(&[]), args: B(&[ - TagArg::Var { + TagArg::Variable { name: B("count"), required: false, + count: TokenCount::Exact(1), }, TagArg::Choice { name: B("method"), @@ -246,6 +260,7 @@ static DEFAULTTAGS_PAIRS: &[(&str, &TagSpec)] = &[ TagArg::Literal { lit: B("random"), required: false, + kind: LiteralKind::Literal, }, ]), }, @@ -264,10 +279,12 @@ static DEFAULTTAGS_PAIRS: &[(&str, &TagSpec)] = &[ TagArg::Literal { lit: B("as"), required: false, + kind: LiteralKind::Syntax, }, - TagArg::Var { + TagArg::Variable { name: B("varname"), required: false, + count: TokenCount::Exact(1), }, ]), }, @@ -282,25 +299,30 @@ static DEFAULTTAGS_PAIRS: &[(&str, &TagSpec)] = &[ end_tag: None, intermediate_tags: B(&[]), args: B(&[ - TagArg::Var { + TagArg::Variable { name: B("target"), required: true, + count: TokenCount::Exact(1), }, TagArg::Literal { lit: B("by"), required: true, + kind: LiteralKind::Syntax, }, - TagArg::Var { + TagArg::Variable { name: B("attribute"), required: true, + count: TokenCount::Exact(1), }, TagArg::Literal { lit: B("as"), required: true, + kind: LiteralKind::Syntax, }, - TagArg::Var { + TagArg::Variable { name: B("grouped"), required: true, + count: TokenCount::Exact(1), }, ]), }, @@ -359,10 +381,12 @@ static DEFAULTTAGS_PAIRS: &[(&str, &TagSpec)] = &[ TagArg::Literal { lit: B("as"), required: false, + kind: LiteralKind::Syntax, }, - TagArg::Var { + TagArg::Variable { name: B("varname"), required: false, + count: TokenCount::Exact(1), }, ]), }, @@ -390,25 +414,30 @@ static DEFAULTTAGS_PAIRS: &[(&str, &TagSpec)] = &[ end_tag: None, intermediate_tags: B(&[]), args: B(&[ - TagArg::Var { + TagArg::Variable { name: B("this_value"), required: true, + count: TokenCount::Exact(1), }, - TagArg::Var { + TagArg::Variable { name: B("max_value"), required: true, + count: TokenCount::Exact(1), }, - TagArg::Var { + TagArg::Variable { name: B("max_width"), required: true, + count: TokenCount::Exact(1), }, TagArg::Literal { lit: B("as"), required: false, + kind: LiteralKind::Syntax, }, - TagArg::Var { + TagArg::Variable { name: B("varname"), required: false, + count: TokenCount::Exact(1), }, ]), }, @@ -440,16 +469,18 @@ static LOADER_TAGS_PAIRS: &[(&str, &TagSpec)] = &[ end_tag: Some(EndTag { name: B("endblock"), required: true, - args: B(&[TagArg::Var { + args: B(&[TagArg::Variable { name: B("name"), required: false, + count: TokenCount::Exact(1), }]), }), intermediate_tags: B(&[]), - args: B(&[TagArg::Var { - name: B("name"), - required: true, - }]), + args: B(&[TagArg::Variable { + name: B("name"), + required: true, + count: TokenCount::Exact(1), + }]), }, ), ( @@ -478,6 +509,7 @@ static LOADER_TAGS_PAIRS: &[(&str, &TagSpec)] = &[ TagArg::Literal { lit: B("with"), required: false, + kind: LiteralKind::Syntax, }, TagArg::VarArgs { name: B("context"), @@ -486,6 +518,7 @@ static LOADER_TAGS_PAIRS: &[(&str, &TagSpec)] = &[ TagArg::Literal { lit: B("only"), required: false, + kind: LiteralKind::Modifier, }, ]), }, @@ -504,14 +537,16 @@ static CACHE_PAIRS: &[(&str, &TagSpec)] = &[( }), intermediate_tags: B(&[]), args: B(&[ - TagArg::Var { - name: B("timeout"), - required: true, - }, - TagArg::Var { - name: B("cache_key"), - required: true, - }, + TagArg::Variable { + name: B("timeout"), + required: true, + count: TokenCount::Exact(1), + }, + TagArg::Variable { + name: B("cache_key"), + required: true, + count: TokenCount::Exact(1), + }, TagArg::VarArgs { name: B("variables"), required: false, @@ -559,10 +594,11 @@ static I18N_PAIRS: &[(&str, &TagSpec)] = &[ ]; const BLOCKTRANS_INTERMEDIATE_TAGS: &[IntermediateTag] = &[IntermediateTag { name: B("plural"), - args: B(&[TagArg::Var { - name: B("count"), - required: false, - }]), + args: B(&[TagArg::Variable { + name: B("count"), + required: false, + count: TokenCount::Exact(1), + }]), }]; const BLOCKTRANS_ARGS: &[TagArg] = &[ TagArg::String { @@ -570,21 +606,24 @@ const BLOCKTRANS_ARGS: &[TagArg] = &[ required: false, }, TagArg::Literal { - lit: B("with"), - required: false, - }, + lit: B("with"), + required: false, + kind: LiteralKind::Syntax, + }, TagArg::VarArgs { name: B("assignments"), required: false, }, TagArg::Literal { - lit: B("asvar"), - required: false, - }, - TagArg::Var { - name: B("varname"), - required: false, - }, + lit: B("asvar"), + required: false, + kind: LiteralKind::Literal, + }, + TagArg::Variable { + name: B("varname"), + required: false, + count: TokenCount::Exact(1), + }, ]; const TRANS_SPEC: TagSpec = TagSpec { module: B(I18N_MOD), @@ -600,17 +639,20 @@ const TRANS_SPEC: TagSpec = TagSpec { required: false, }, TagArg::Literal { - lit: B("as"), - required: false, - }, - TagArg::Var { - name: B("varname"), - required: false, - }, + lit: B("as"), + required: false, + kind: LiteralKind::Syntax, + }, + TagArg::Variable { + name: B("varname"), + required: false, + count: TokenCount::Exact(1), + }, TagArg::Literal { - lit: B("noop"), - required: false, - }, + lit: B("noop"), + required: false, + kind: LiteralKind::Literal, + }, ]), }; @@ -681,10 +723,11 @@ static TZ_PAIRS: &[(&str, &TagSpec)] = &[ args: B(&[]), }), intermediate_tags: B(&[]), - args: B(&[TagArg::Var { - name: B("timezone"), - required: true, - }]), + args: B(&[TagArg::Variable { + name: B("timezone"), + required: true, + count: TokenCount::Exact(1), + }]), }, ), ]; diff --git a/crates/djls-semantic/src/templatetags/specs.rs b/crates/djls-semantic/src/templatetags/specs.rs index d1227b16..7bd0bd40 100644 --- a/crates/djls-semantic/src/templatetags/specs.rs +++ b/crates/djls-semantic/src/templatetags/specs.rs @@ -10,6 +10,32 @@ use rustc_hash::FxHashMap; pub type S = Cow<'static, T>; pub type L = Cow<'static, [T]>; +/// Token consumption strategy for tag arguments. +/// +/// This separates "how many tokens" from "what the tokens mean", +/// allowing v0.6.0 spec semantics to be properly represented. +#[derive(Debug, Clone, PartialEq)] +pub enum TokenCount { + /// Exactly N tokens (count = N in spec) + Exact(usize), + /// Variable/greedy consumption until next literal or end (count = null in spec) + Greedy, +} + +/// Semantic classification for literal arguments. +/// +/// All three map to TagArg::Literal but provide semantic hints +/// for better diagnostics and IDE features. +#[derive(Debug, Clone, PartialEq)] +pub enum LiteralKind { + /// Plain literal token (kind = "literal" in spec) + Literal, + /// Mandatory syntactic token like "in", "as" (kind = "syntax" in spec) + Syntax, + /// Boolean modifier like "reversed", "silent" (kind = "modifier" in spec) + Modifier, +} + #[allow(dead_code)] pub enum TagType { Opener, @@ -228,30 +254,41 @@ impl From<(djls_conf::TagDef, String)> for TagSpec { #[derive(Debug, Clone, PartialEq)] pub enum TagArg { - Var { + /// Template variable or filter expression (kind = "variable") + Variable { name: S, required: bool, + count: TokenCount, }, + /// String literal argument (no direct v0.6.0 equivalent, implementation detail) String { name: S, required: bool, }, + /// Literal token with semantic classification (kind = "literal", "syntax", or "modifier") Literal { lit: S, required: bool, + kind: LiteralKind, }, - Expr { + /// Any template expression or literal (kind = "any") + Any { name: S, required: bool, + count: TokenCount, }, + /// Variable assignment (kind = "assignment") Assignment { name: S, required: bool, + count: TokenCount, }, + /// Consumes all remaining tokens (implementation detail) VarArgs { name: S, required: bool, }, + /// Choice from specific literals (kind = "choice") Choice { name: S, required: bool, @@ -263,9 +300,9 @@ impl TagArg { #[must_use] pub fn name(&self) -> &S { match self { - Self::Var { name, .. } + Self::Variable { name, .. } | Self::String { name, .. } - | Self::Expr { name, .. } + | Self::Any { name, .. } | Self::Assignment { name, .. } | Self::VarArgs { name, .. } | Self::Choice { name, .. } => name, @@ -276,10 +313,10 @@ impl TagArg { #[must_use] pub fn is_required(&self) -> bool { match self { - Self::Var { required, .. } + Self::Variable { required, .. } | Self::String { required, .. } | Self::Literal { required, .. } - | Self::Expr { required, .. } + | Self::Any { required, .. } | Self::Assignment { required, .. } | Self::VarArgs { required, .. } | Self::Choice { required, .. } => *required, @@ -294,17 +331,39 @@ impl TagArg { } } + /// Create an Any argument with greedy token consumption (replaces old expr()) pub fn expr(name: impl Into, required: bool) -> Self { - Self::Expr { + Self::Any { name: name.into(), required, + count: TokenCount::Greedy, } } + /// Create a literal with Literal kind (plain literal) pub fn literal(lit: impl Into, required: bool) -> Self { Self::Literal { lit: lit.into(), required, + kind: LiteralKind::Literal, + } + } + + /// Create a literal with Syntax kind (keywords like "in", "as") + pub fn syntax(lit: impl Into, required: bool) -> Self { + Self::Literal { + lit: lit.into(), + required, + kind: LiteralKind::Syntax, + } + } + + /// Create a literal with Modifier kind (modifiers like "reversed") + pub fn modifier(lit: impl Into, required: bool) -> Self { + Self::Literal { + lit: lit.into(), + required, + kind: LiteralKind::Modifier, } } @@ -315,10 +374,21 @@ impl TagArg { } } + /// Create a Variable argument with single token consumption (replaces old var()) pub fn var(name: impl Into, required: bool) -> Self { - Self::Var { + Self::Variable { + name: name.into(), + required, + count: TokenCount::Exact(1), + } + } + + /// Create a Variable argument with greedy token consumption + pub fn var_greedy(name: impl Into, required: bool) -> Self { + Self::Variable { name: name.into(), required, + count: TokenCount::Greedy, } } @@ -329,34 +399,54 @@ impl TagArg { } } + /// Create an Assignment argument with greedy token consumption pub fn assignment(name: impl Into, required: bool) -> Self { Self::Assignment { name: name.into(), required, + count: TokenCount::Greedy, } } } impl From for TagArg { fn from(value: djls_conf::TagArgDef) -> Self { + // Convert count: Option to TokenCount + let token_count = match value.count { + Some(n) => TokenCount::Exact(n), + None => TokenCount::Greedy, + }; + match value.kind { - djls_conf::ArgKindDef::Literal - | djls_conf::ArgKindDef::Syntax - | djls_conf::ArgKindDef::Modifier => TagArg::Literal { + djls_conf::ArgKindDef::Literal => TagArg::Literal { + lit: value.name.into(), + required: value.required, + kind: LiteralKind::Literal, + }, + djls_conf::ArgKindDef::Syntax => TagArg::Literal { + lit: value.name.into(), + required: value.required, + kind: LiteralKind::Syntax, + }, + djls_conf::ArgKindDef::Modifier => TagArg::Literal { lit: value.name.into(), required: value.required, + kind: LiteralKind::Modifier, }, - djls_conf::ArgKindDef::Variable => TagArg::Var { + djls_conf::ArgKindDef::Variable => TagArg::Variable { name: value.name.into(), required: value.required, + count: token_count, }, - djls_conf::ArgKindDef::Any => TagArg::Expr { + djls_conf::ArgKindDef::Any => TagArg::Any { name: value.name.into(), required: value.required, + count: token_count, }, djls_conf::ArgKindDef::Assignment => TagArg::Assignment { name: value.name.into(), required: value.required, + count: token_count, }, djls_conf::ArgKindDef::Choice => { // Extract choices from extra metadata @@ -503,9 +593,10 @@ mod tests { end_tag: Some(EndTag { name: "endblock".into(), required: true, - args: Cow::Owned(vec![TagArg::Var { + args: Cow::Owned(vec![TagArg::Variable { name: "name".into(), required: false, + count: TokenCount::Exact(1), }]), }), intermediate_tags: Cow::Borrowed(&[]), @@ -767,10 +858,12 @@ mod tests { extra: None, }; let arg = TagArg::from(var_arg_def); - assert!(matches!(arg, TagArg::Var { .. })); - if let TagArg::Var { name, required } = arg { + assert!(matches!(arg, TagArg::Variable { .. })); + if let TagArg::Variable { name, required, count } = arg { assert_eq!(name.as_ref(), "test_arg"); assert!(required); + // count is None in spec, so should be Greedy + assert_eq!(count, TokenCount::Greedy); } // Test choice argument with extra metadata diff --git a/noxfile.py b/noxfile.py index 8bc23a15..4d48275c 100644 --- a/noxfile.py +++ b/noxfile.py @@ -163,6 +163,27 @@ def lint(session): session.error("Linting failed with all Python versions") +@nox.session +@nox.parametrize("django", [DJ42, DJ51, DJ52, DJ60, DJMAIN]) +def analyze_tags(session, django): + """Analyze Django template tags and generate TagSpec suggestions.""" + if django == DJMAIN: + session.install( + "django @ https://github.com/django/django/archive/refs/heads/main.zip" + ) + else: + session.install(f"django=={django}") + + session.run( + "python", + "scripts/analyze_django_tags.py", + "--version", + django, + "--output-dir", + "analysis", + ) + + @nox.session def copy_bench_fixtures(session): django_version = (