diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6df637a..96041c6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -154,7 +154,7 @@ cargo-build: stage: build image: rust:latest script: - - cargo build --release + - cargo build --workspace --all-targets --release artifacts: paths: - target/release/ diff --git a/src/bridges/VclTotalBridge.res b/src/bridges/VclTotalBridge.res index 0b8f3d1..3c7fc20 100644 --- a/src/bridges/VclTotalBridge.res +++ b/src/bridges/VclTotalBridge.res @@ -49,18 +49,7 @@ type checkReport = { /// against a schema — that requires the TypeLL server. let checkQuery = (queryStr: string): result => { switch VclTotalParser.parse(queryStr) { - | Error(msg) => - Ok({ - valid: false, - maxLevel: 0, - maxLevelName: "Parse-time safety", - queryPath: "VCL (Slipstream)", - levelDiagnostics: [`Parse error: ${msg}`], - explanation: `Parse failed: ${msg}`, - ast: None, - effects: [], - usage: "omega", - }) + | Error(msg) => Error(`Parse failed: ${msg}`) | Ok(stmt) => // Determine max level from present clauses let maxLevel = VclTotalAst.safetyLevelToInt(stmt.requestedLevel) @@ -119,20 +108,30 @@ let checkQuery = (queryStr: string): result => { } } +/// Escape a string for safe inclusion in a JSON string value. +let escapeJsonString = (s: string): string => + s + ->String.replaceAll("\\", "\\\\") + ->String.replaceAll("\"", "\\\"") + ->String.replaceAll("\n", "\\n") + ->String.replaceAll("\r", "\\r") + ->String.replaceAll("\t", "\\t") + /// Encode a checkReport as JSON for transport to TypeLL server. let reportToJson = (report: checkReport): string => { let effectsJson = report.effects - ->Array.map(e => `"${e}"`) + ->Array.map(e => `"${escapeJsonString(e)}"`) ->Array.join(", ") let diagnosticsJson = report.levelDiagnostics - ->Array.map(d => { - let escaped = d->String.replaceAll("\"", "\\\"") - `"${escaped}"` - }) + ->Array.map(d => `"${escapeJsonString(d)}"`) ->Array.join(", ") - `{"valid":${report.valid ? "true" : "false"},"maxLevel":${Int.toString(report.maxLevel)},"maxLevelName":"${report.maxLevelName}","queryPath":"${report.queryPath}","effects":[${effectsJson}],"usage":"${report.usage}","levelDiagnostics":[${diagnosticsJson}]}` + let maxLevelName = escapeJsonString(report.maxLevelName) + let queryPath = escapeJsonString(report.queryPath) + let usage = escapeJsonString(report.usage) + + `{"valid":${report.valid ? "true" : "false"},"maxLevel":${Int.toString(report.maxLevel)},"maxLevelName":"${maxLevelName}","queryPath":"${queryPath}","effects":[${effectsJson}],"usage":"${usage}","levelDiagnostics":[${diagnosticsJson}]}` } /// Parse a query and return just the safety level (quick check). diff --git a/src/core/Checker.idr b/src/core/Checker.idr index 334890f..438bb66 100644 --- a/src/core/Checker.idr +++ b/src/core/Checker.idr @@ -132,29 +132,6 @@ extractFieldRefs (EAggregate _ e _) = extractFieldRefs e extractFieldRefs (EParam _ _) = [] extractFieldRefs EStar = [] extractFieldRefs (ESubquery sub) = statementFieldRefs sub - where - ||| Collect field references from all clauses of a statement. - ||| Gathers from: selectItems, whereClause, groupBy, having, orderBy. - statementFieldRefs : Statement -> List FieldRef - statementFieldRefs stmt = - let selRefs : List FieldRef - selRefs = concatMap selItemRefs (selectItems stmt) - whereRefs : List FieldRef - whereRefs = maybe [] extractFieldRefs (whereClause stmt) - groupRefs : List FieldRef - groupRefs = groupBy stmt - havingRefs : List FieldRef - havingRefs = maybe [] extractFieldRefs (having stmt) - orderRefs : List FieldRef - orderRefs = map fst (orderBy stmt) - in selRefs ++ whereRefs ++ groupRefs ++ havingRefs ++ orderRefs - - ||| Extract field references from a single SELECT item. - selItemRefs : SelectItem -> List FieldRef - selItemRefs (SelField ref) = [ref] - selItemRefs (SelModality _) = [] - selItemRefs (SelAggregate _ e) = extractFieldRefs e - selItemRefs SelStar = [] ||| Collect all field references from every clause of a statement. ||| Delegates to extractFieldRefs for each expression-bearing clause. diff --git a/src/errors/VclTotalError.res b/src/errors/VclTotalError.res index d068082..52be430 100644 --- a/src/errors/VclTotalError.res +++ b/src/errors/VclTotalError.res @@ -20,6 +20,7 @@ type errorCode = | TypeError // Level 2: incompatible types in comparison | NullError // Level 3: nullable field used without guard | InjectionAttempt // Level 4: raw string literal in unsafe position + | ResultTypeError // Level 5: unresolved result type (TAny in select) | CardinalityViolation // Level 6: unbounded result without LIMIT | EffectViolation // Level 7: undeclared effect | TemporalBoundsExceeded // Level 8: version constraint violation @@ -43,11 +44,12 @@ let errorCodeToInt = (code: errorCode): int => | TypeError => 3 | NullError => 4 | InjectionAttempt => 5 - | CardinalityViolation => 6 - | EffectViolation => 7 - | TemporalBoundsExceeded => 8 - | LinearityViolation => 9 - | InternalError => 10 + | ResultTypeError => 6 + | CardinalityViolation => 7 + | EffectViolation => 8 + | TemporalBoundsExceeded => 9 + | LinearityViolation => 10 + | InternalError => 11 } /// Convert an ABI integer tag to an error code. @@ -58,11 +60,12 @@ let errorCodeFromInt = (n: int): option => | 3 => Some(TypeError) | 4 => Some(NullError) | 5 => Some(InjectionAttempt) - | 6 => Some(CardinalityViolation) - | 7 => Some(EffectViolation) - | 8 => Some(TemporalBoundsExceeded) - | 9 => Some(LinearityViolation) - | 10 => Some(InternalError) + | 6 => Some(ResultTypeError) + | 7 => Some(CardinalityViolation) + | 8 => Some(EffectViolation) + | 9 => Some(TemporalBoundsExceeded) + | 10 => Some(LinearityViolation) + | 11 => Some(InternalError) | _ => None } diff --git a/src/interface/dap/src/main.rs b/src/interface/dap/src/main.rs index e3ced89..e47d8ec 100644 --- a/src/interface/dap/src/main.rs +++ b/src/interface/dap/src/main.rs @@ -165,23 +165,35 @@ fn handle_client(stream: TcpStream) -> Result<(), Box> { })? } "continue" => { - // TODO: Execute the query using database-mcp cartridge - // For now, return a dummy response - let query = if let Some(args) = &request.arguments { - args.get("query").and_then(|q| q.as_str()).unwrap_or("").to_string() + let query = request.arguments + .as_ref() + .and_then(|args| args.get("query")) + .and_then(|q| q.as_str()) + .unwrap_or("") + .to_string(); + + if query.is_empty() { + serde_json::to_string(&DapResponse { + seq: 9, + r#type: "response".to_string(), + request_seq: request.seq, + command: "continue".to_string(), + success: false, + message: Some("Missing or invalid 'query' argument".to_string()), + body: None, + })? } else { - "".to_string() - }; - let result = execute_vql_query(&query); - serde_json::to_string(&DapResponse { - seq: 9, - r#type: "response".to_string(), - request_seq: request.seq, - command: "continue".to_string(), - success: true, - message: Some(format!("Query executed: {}", result)), - body: None, - })? + let result = execute_vql_query(&query); + serde_json::to_string(&DapResponse { + seq: 9, + r#type: "response".to_string(), + request_seq: request.seq, + command: "continue".to_string(), + success: true, + message: Some(format!("Query executed: {}", result)), + body: None, + })? + } } "disconnect" => { serde_json::to_string(&DapResponse { diff --git a/src/interface/fmt/src/lib.rs b/src/interface/fmt/src/lib.rs index fd84b8d..16ddb96 100644 --- a/src/interface/fmt/src/lib.rs +++ b/src/interface/fmt/src/lib.rs @@ -13,11 +13,20 @@ /// All lines are trimmed of leading/trailing whitespace before processing. pub fn format_vqlut(content: &str) -> String { let mut formatted = String::new(); - let keywords = ["SELECT", "FROM", "WHERE", "GROUP", "ORDER", "HAVING", "LIMIT"]; + let keywords = [ + "SELECT", "FROM", "WHERE", "GROUP", "ORDER", "HAVING", "LIMIT", + "OFFSET", "EFFECTS", "PROOF", "CONSUME", + ]; for line in content.lines() { let trimmed = line.trim(); - if keywords.iter().any(|&kw| trimmed.starts_with(kw)) { + if keywords.iter().any(|&kw| { + trimmed.starts_with(kw) + && trimmed + .as_bytes() + .get(kw.len()) + .map_or(true, |&b| !b.is_ascii_alphanumeric() && b != b'_') + }) { formatted.push_str(" "); } formatted.push_str(trimmed); diff --git a/src/interface/fmt/src/main.rs b/src/interface/fmt/src/main.rs index 2ffbd7c..3e73e5f 100644 --- a/src/interface/fmt/src/main.rs +++ b/src/interface/fmt/src/main.rs @@ -27,7 +27,7 @@ fn main() { let content = fs::read_to_string(&input_path).expect("Unable to read file"); // Format the content (basic indentation for now) - let formatted = vqlut_fmt::format_vqlut(&content); + let formatted = vcltotal_fmt::format_vqlut(&content); // Write the output file let output_path = args.output.unwrap_or(input_path); diff --git a/src/interface/lint/src/lib.rs b/src/interface/lint/src/lib.rs index 3b56199..14b59fc 100644 --- a/src/interface/lint/src/lib.rs +++ b/src/interface/lint/src/lib.rs @@ -4,7 +4,7 @@ //! VCL-total Linting Library //! //! Provides linting capabilities for VCL-total query files. -//! Checks for missing semicolons and lowercase keywords. +//! Checks for missing semicolons, lowercase keywords, SELECT *, and OFFSET without LIMIT. /// A single lint issue found in VCL-total content. #[derive(Debug)] @@ -19,8 +19,10 @@ pub struct LintIssue { /// /// Current checks: /// - Missing semicolons at the end of non-empty lines -/// - Lowercase keywords (select, from, where, group, order, having, limit) +/// - Lowercase keywords (SQL and VCL-total extension keywords) /// when surrounded by spaces (` keyword `) +/// - `SELECT *` usage (prefer explicit columns for Level 5 result typing) +/// - `OFFSET` without `LIMIT` (likely a mistake) pub fn lint_vqlut(content: &str) -> Vec { let mut issues = Vec::new(); @@ -36,16 +38,63 @@ pub fn lint_vqlut(content: &str) -> Vec { } } - // Check for uppercase keywords + // Check for lowercase keywords: flag keywords that appear (case-insensitive) + // but are not already uppercase in the original text. + let keywords = [ + "select", "from", "where", "group", "order", "having", "limit", + "offset", "effects", "proof", "consume", "usage", + ]; for (i, line) in content.lines().enumerate() { let line_num = i + 1; - let keywords = ["select", "from", "where", "group", "order", "having", "limit"]; for keyword in keywords { - if line.to_lowercase().contains(&format!(" {} ", keyword)) { + let lower_pattern = format!(" {} ", keyword); + if line.to_lowercase().contains(&lower_pattern) { + let upper_pattern = format!(" {} ", keyword.to_uppercase()); + if !line.contains(&upper_pattern) { + issues.push(LintIssue { + line: line_num, + message: format!( + "Keyword '{}' should be uppercase", + keyword.to_uppercase() + ), + }); + } + } + } + } + + // Check for SELECT * (prefer explicit columns for Level 5 result typing) + let upper_content = content.to_uppercase(); + for (i, line) in upper_content.lines().enumerate() { + let line_num = i + 1; + let trimmed = line.trim(); + if trimmed.starts_with("SELECT") && trimmed.contains("SELECT *") { + issues.push(LintIssue { + line: line_num, + message: "Prefer explicit column list over SELECT * for result-type safety (Level 5)".to_string(), + }); + } + } + + // Check for OFFSET without LIMIT (likely a mistake — OFFSET is meaningless without LIMIT) + let has_offset = upper_content.lines().any(|l| { + let t = l.trim(); + t.starts_with("OFFSET") || t.contains(" OFFSET ") + }); + let has_limit = upper_content.lines().any(|l| { + let t = l.trim(); + t.starts_with("LIMIT") || t.contains(" LIMIT ") + }); + if has_offset && !has_limit { + // Report on the line containing OFFSET + for (i, line) in upper_content.lines().enumerate() { + let trimmed = line.trim(); + if trimmed.starts_with("OFFSET") || trimmed.contains(" OFFSET ") { issues.push(LintIssue { - line: line_num, - message: format!("Keyword '{}' should be uppercase", keyword.to_uppercase()), + line: i + 1, + message: "OFFSET without LIMIT has no effect".to_string(), }); + break; } } } diff --git a/src/interface/lint/src/main.rs b/src/interface/lint/src/main.rs index 4b8f43f..3c882e0 100644 --- a/src/interface/lint/src/main.rs +++ b/src/interface/lint/src/main.rs @@ -23,7 +23,7 @@ fn main() { let content = fs::read_to_string(&input_path).expect("Unable to read file"); // Lint the content - let issues = vqlut_lint::lint_vqlut(&content); + let issues = vcltotal_lint::lint_vqlut(&content); // Print the issues for issue in &issues { diff --git a/src/interface/lsp/src/lib.rs b/src/interface/lsp/src/lib.rs index 25b270c..c0ab907 100644 --- a/src/interface/lsp/src/lib.rs +++ b/src/interface/lsp/src/lib.rs @@ -127,14 +127,7 @@ impl VqlutLsp { }) } - pub fn handle_completion(&self, params: CompletionParams) -> Option { - // Extract the position from the params - let position = params.text_document_position.position; - let line = position.line as usize; - let character = position.character as usize; - - // TODO: Parse the VCL-total file at the given position to suggest completions - // For now, return a dummy response with some VCL-total keywords and schema + pub fn handle_completion(&self, _params: CompletionParams) -> Option { let mut items = vec![ CompletionItem { label: "SELECT".to_string(), @@ -154,6 +147,66 @@ impl VqlutLsp { detail: Some("VCL-total WHERE keyword".to_string()), ..Default::default() }, + CompletionItem { + label: "GROUP BY".to_string(), + kind: Some(CompletionItemKind::KEYWORD), + detail: Some("VCL-total GROUP BY clause".to_string()), + ..Default::default() + }, + CompletionItem { + label: "ORDER BY".to_string(), + kind: Some(CompletionItemKind::KEYWORD), + detail: Some("VCL-total ORDER BY clause".to_string()), + ..Default::default() + }, + CompletionItem { + label: "HAVING".to_string(), + kind: Some(CompletionItemKind::KEYWORD), + detail: Some("VCL-total HAVING clause".to_string()), + ..Default::default() + }, + CompletionItem { + label: "LIMIT".to_string(), + kind: Some(CompletionItemKind::KEYWORD), + detail: Some("VCL-total LIMIT clause (Level 6: cardinality safety)".to_string()), + ..Default::default() + }, + CompletionItem { + label: "OFFSET".to_string(), + kind: Some(CompletionItemKind::KEYWORD), + detail: Some("VCL-total OFFSET clause".to_string()), + ..Default::default() + }, + CompletionItem { + label: "EFFECTS".to_string(), + kind: Some(CompletionItemKind::KEYWORD), + detail: Some("VCL-total EFFECTS clause (Level 7: effect tracking)".to_string()), + ..Default::default() + }, + CompletionItem { + label: "PROOF ATTACHED".to_string(), + kind: Some(CompletionItemKind::KEYWORD), + detail: Some("VCL-total PROOF clause (Level 4+: injection proof)".to_string()), + ..Default::default() + }, + CompletionItem { + label: "AT VERSION".to_string(), + kind: Some(CompletionItemKind::KEYWORD), + detail: Some("VCL-total version constraint (Level 8: temporal safety)".to_string()), + ..Default::default() + }, + CompletionItem { + label: "CONSUME AFTER".to_string(), + kind: Some(CompletionItemKind::KEYWORD), + detail: Some("VCL-total linearity annotation (Level 9: linear safety)".to_string()), + ..Default::default() + }, + CompletionItem { + label: "USAGE LIMIT".to_string(), + kind: Some(CompletionItemKind::KEYWORD), + detail: Some("VCL-total bounded usage (Level 9: linear safety)".to_string()), + ..Default::default() + }, ]; // Add schema-based completions (tables and columns) diff --git a/src/interface/lsp/src/main.rs b/src/interface/lsp/src/main.rs index 51681d3..286db23 100644 --- a/src/interface/lsp/src/main.rs +++ b/src/interface/lsp/src/main.rs @@ -2,13 +2,13 @@ //! Language Server Protocol (LSP) implementation for VCL-total //! //! This server provides LSP support for the VCL-total query language. +//! Uses lsp-server (synchronous) for the transport layer. use lsp_server::{Connection, Message, RequestId, Response}; use lsp_types::*; use std::error::Error; -mod lib; -use lib::VqlutLsp; +use vcltotal_lsp::VqlutLsp; /// Send an LSP result response, converting serialization failures to LSP /// error responses rather than panicking. This ensures the server never @@ -39,18 +39,30 @@ fn send_result( Ok(()) } -#[tokio::main] -async fn main() -> Result<(), Box> { - // Create the transport (stdio, TCP, etc.) +fn cast(req: lsp_server::Request) -> Result<(RequestId, R::Params), lsp_server::Request> +where + R: lsp_types::request::Request, +{ + req.extract(R::METHOD).map_err(|e| match e { + lsp_server::ExtractError::MethodMismatch(req) => req, + lsp_server::ExtractError::JsonError { method: _, error: _ } => { + // Deserialization failed — treat as unhandled (cannot recover the original request) + panic!("JSON deserialization failed for LSP request") + } + }) +} + +fn main() -> Result<(), Box> { + // Create the transport (stdio) let (connection, io_threads) = Connection::stdio(); // Initialize VCL-total LSP let vqlut_lsp = VqlutLsp::new(); - // Run the server and wait for the two threads to end. + // Declare server capabilities let server_capabilities = serde_json::to_value(ServerCapabilities { text_document_sync: Some(TextDocumentSyncCapability::Kind( - TextDocumentSyncKind::Incremental, + TextDocumentSyncKind::FULL, )), hover_provider: Some(HoverProviderCapability::Simple(true)), completion_provider: Some(CompletionOptions { @@ -62,14 +74,13 @@ async fn main() -> Result<(), Box> { ..Default::default() })?; - let initialization_params = connection.initialize(server_capabilities).await?; - let _initialized = initialization_params; + let _initialization_params = connection.initialize(server_capabilities)?; - // Main loop - while let Some(msg) = connection.receiver.recv().await { + // Main message loop (lsp-server is synchronous) + for msg in &connection.receiver { match msg { Message::Request(req) => { - if connection.handle_shutdown(&req).await? { + if connection.handle_shutdown(&req)? { return Ok(()); } match cast::(req.clone()) { @@ -77,40 +88,32 @@ async fn main() -> Result<(), Box> { let result = vqlut_lsp.handle_goto_definition(params); send_result(&connection, id, &result)?; } - _ => match cast::(req.clone()) { + Err(req) => match cast::(req) { Ok((id, params)) => { let result = vqlut_lsp.handle_hover(params); send_result(&connection, id, &result)?; } - _ => match cast::(req.clone()) { + Err(req) => match cast::(req) { Ok((id, params)) => { let result = vqlut_lsp.handle_completion(params); send_result(&connection, id, &result)?; } - _ => { - eprintln!("Unknown request: {:?}", req); + Err(req) => { + eprintln!("Unhandled request: {:?}", req.method); } }, }, } } - Message::Notification(not) => { - if connection.handle_shutdown(¬).await? { - return Ok(()); - } + Message::Notification(_not) => { + // Notifications are fire-and-forget; no response needed. } Message::Response(resp) => { - eprintln!("Got response: {:?}", resp); + eprintln!("Unexpected response: {:?}", resp); } } } + io_threads.join()?; Ok(()) } - -fn cast(req: request::Request) -> Result<(RequestId, U::Params), request::Request> -where - U: lsp_types::request::Request, -{ - req.extract(U::METHOD) -} diff --git a/tests/e2e_test.rs b/tests/e2e_test.rs index 5f3f1e7..6b693c8 100644 --- a/tests/e2e_test.rs +++ b/tests/e2e_test.rs @@ -191,9 +191,6 @@ fn e2e_formatter_does_not_introduce_semicolon_issues() { // The formatter must not strip semicolons from the input. // A single-line query with a semicolon, once formatted, must not acquire // a 'missing semicolon' lint issue. - // Note: the linter uses a space-delimited, case-insensitive keyword check, - // so even well-formatted queries may trigger keyword-case issues — that is - // documented linter behaviour, not a bug introduced by the formatter. let query = "SELECT id;"; let formatted = format_vqlut(query); let issues = lint_vqlut(&formatted); diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 1a7c343..1d8283d 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -185,12 +185,8 @@ fn lint_detects_lowercase_where() { } #[test] -fn lint_flags_keywords_case_insensitively() { - // The linter lowercases the entire line before searching for ` keyword `. - // This means even uppercase keywords surrounded by spaces are flagged, - // because "SELECT * FROM users" lowercased contains " from ". - // This documents the current behavior: the linter always flags keywords - // found via the ` keyword ` pattern regardless of original case. +fn lint_does_not_flag_uppercase_keywords() { + // Keywords that are already uppercase should not be flagged. let input = "SELECT * FROM users WHERE id = 1;"; let issues = lint_vqlut(input); let kw_issues: Vec<&LintIssue> = issues @@ -198,8 +194,9 @@ fn lint_flags_keywords_case_insensitively() { .filter(|i| i.message.contains("should be uppercase")) .collect(); assert!( - !kw_issues.is_empty(), - "Current linter flags keywords found via case-insensitive space-delimited search" + kw_issues.is_empty(), + "Uppercase keywords should not be flagged. Got: {:?}", + kw_issues.iter().map(|i| &i.message).collect::>() ); } @@ -369,13 +366,13 @@ fn aspect_keyword_in_middle_of_line_not_indented() { #[test] fn aspect_keyword_as_substring_not_indented() { - // "SELECTED" starts with SELECT but is not SELECT keyword - // Actually it does start with "SELECT" so it WILL be indented by the current logic + // "SELECTED" starts with "SELECT" but is not the SELECT keyword. + // The formatter checks word boundaries, so it should NOT be indented. let output = format_vqlut("SELECTED * FROM users"); - // This is expected behavior: starts_with("SELECT") matches "SELECTED" assert!( - output.starts_with(" SELECTED"), - "Current logic indents lines starting with keyword prefix" + !output.starts_with(" "), + "Keyword-prefix words should not be indented. Got: {:?}", + output ); } @@ -535,6 +532,116 @@ fn level_10_linearity_single_use() { assert!(semicolon_issues.is_empty()); } +// ============================================================================ +// Point-to-point: Formatter — VCL-total extension keywords +// ============================================================================ + +#[test] +fn fmt_indents_offset() { + let output = format_vqlut("OFFSET 10"); + assert!(output.starts_with(" OFFSET"), "OFFSET should be indented"); +} + +#[test] +fn fmt_indents_effects() { + let output = format_vqlut("EFFECTS { Read }"); + assert!(output.starts_with(" EFFECTS"), "EFFECTS should be indented"); +} + +#[test] +fn fmt_indents_proof() { + let output = format_vqlut("PROOF ATTACHED"); + assert!(output.starts_with(" PROOF"), "PROOF should be indented"); +} + +#[test] +fn fmt_indents_consume() { + let output = format_vqlut("CONSUME AFTER 1 USE"); + assert!(output.starts_with(" CONSUME"), "CONSUME should be indented"); +} + +#[test] +fn fmt_does_not_indent_offset_prefix() { + // "OFFSETTING" starts with OFFSET but is not the keyword + let output = format_vqlut("OFFSETTING values"); + assert!(!output.starts_with(" "), "OFFSETTING should not be indented"); +} + +// ============================================================================ +// Point-to-point: Linter — VCL-total semantic checks +// ============================================================================ + +#[test] +fn lint_detects_select_star() { + let input = "SELECT * FROM users;"; + let issues = lint_vqlut(input); + let star_issues: Vec<&LintIssue> = issues + .iter() + .filter(|i| i.message.contains("SELECT *")) + .collect(); + assert!( + !star_issues.is_empty(), + "Should warn about SELECT * for result-type safety" + ); +} + +#[test] +fn lint_no_select_star_warning_for_explicit_columns() { + let input = "SELECT id, name FROM users;"; + let issues = lint_vqlut(input); + let star_issues: Vec<&LintIssue> = issues + .iter() + .filter(|i| i.message.contains("SELECT *")) + .collect(); + assert!( + star_issues.is_empty(), + "Should not warn when explicit columns are listed" + ); +} + +#[test] +fn lint_detects_offset_without_limit() { + let input = "SELECT id FROM users\nOFFSET 10;"; + let issues = lint_vqlut(input); + let offset_issues: Vec<&LintIssue> = issues + .iter() + .filter(|i| i.message.contains("OFFSET without LIMIT")) + .collect(); + assert!( + !offset_issues.is_empty(), + "Should warn about OFFSET without LIMIT" + ); +} + +#[test] +fn lint_no_offset_warning_when_limit_present() { + let input = "SELECT id FROM users\nLIMIT 10\nOFFSET 5;"; + let issues = lint_vqlut(input); + let offset_issues: Vec<&LintIssue> = issues + .iter() + .filter(|i| i.message.contains("OFFSET without LIMIT")) + .collect(); + assert!( + offset_issues.is_empty(), + "Should not warn about OFFSET when LIMIT is present" + ); +} + +#[test] +fn lint_detects_lowercase_vcl_extension_keywords() { + let input = "x effects y consume z;"; + let issues = lint_vqlut(input); + let kw_issues: Vec<&LintIssue> = issues + .iter() + .filter(|i| i.message.contains("should be uppercase")) + .collect(); + assert!( + kw_issues.len() >= 2, + "Should detect lowercase 'effects' and 'consume'. Got: {}", + kw_issues.len() + ); +} + // ============================================================================ // Stress and regression tests // ============================================================================