From 987eaa443548fa2969b1f9a8915f1f444dcc8c75 Mon Sep 17 00:00:00 2001 From: Steve Dignam Date: Sun, 29 Jun 2025 22:32:54 -0400 Subject: [PATCH 1/3] vscode: add linting diagnostics --- Cargo.lock | 90 ++++++++++-- Cargo.toml | 3 + crates/squawk/Cargo.toml | 3 + crates/squawk/src/github.rs | 8 +- crates/squawk/src/main.rs | 164 +++++++++++---------- crates/squawk/src/server.rs | 93 ++++++++++++ crates/squawk_server/Cargo.toml | 22 +++ crates/squawk_server/README.md | 3 + crates/squawk_server/src/lib.rs | 212 ++++++++++++++++++++++++++++ crates/squawk_syntax/src/parsing.rs | 40 ------ squawk-vscode/README.md | 5 + squawk-vscode/package.json | 30 +++- squawk-vscode/pnpm-lock.yaml | 33 +++++ squawk-vscode/src/extension.ts | 95 ++++++++++--- 14 files changed, 651 insertions(+), 150 deletions(-) create mode 100644 crates/squawk/src/server.rs create mode 100644 crates/squawk_server/Cargo.toml create mode 100644 crates/squawk_server/README.md create mode 100644 crates/squawk_server/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index c5b555bd..3e6de76e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -488,6 +488,15 @@ dependencies = [ "cfg-if 1.0.0", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils 0.8.21", +] + [[package]] name = "crossbeam-deque" version = "0.7.4" @@ -495,7 +504,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c20ff29ded3204c5106278a81a38f4b482636ed4fa1e6cfbeef193291beb29ed" dependencies = [ "crossbeam-epoch", - "crossbeam-utils", + "crossbeam-utils 0.7.2", "maybe-uninit", ] @@ -507,7 +516,7 @@ checksum = "058ed274caafc1f60c4997b5fc07bf7dc7cca454af7c6e81edffe5f33f70dace" dependencies = [ "autocfg 1.1.0", "cfg-if 0.1.10", - "crossbeam-utils", + "crossbeam-utils 0.7.2", "lazy_static", "maybe-uninit", "memoffset", @@ -521,7 +530,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "774ba60a54c213d409d5353bda12d49cd68d14e45036a285234c8d6f91f92570" dependencies = [ "cfg-if 0.1.10", - "crossbeam-utils", + "crossbeam-utils 0.7.2", "maybe-uninit", ] @@ -536,6 +545,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "dir-test" version = "0.4.1" @@ -1455,6 +1470,32 @@ version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "lsp-server" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9462c4dc73e17f971ec1f171d44bfffb72e65a130117233388a0ebc7ec5656f9" +dependencies = [ + "crossbeam-channel", + "log", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "lsp-types" +version = "0.95.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e34d33a8e9b006cd3fc4fe69a921affa097bae4bb65f76271f4644f9a334365" +dependencies = [ + "bitflags 1.3.2", + "serde", + "serde_json", + "serde_repr", + "url 2.5.4", +] + [[package]] name = "matches" version = "0.1.9" @@ -2417,11 +2458,12 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.82" +version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82c2c1fdcd807d1098552c5b9a36e425e42e9fbd7c6a37a8425f390f781f7fa7" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ "itoa 1.0.2", + "memchr", "ryu", "serde", ] @@ -2435,6 +2477,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "serde_urlencoded" version = "0.5.5" @@ -2555,12 +2608,15 @@ dependencies = [ "insta", "line-index", "log", + "lsp-server", + "lsp-types", "serde", "serde_json", "simplelog", "squawk_github", "squawk_lexer", "squawk_linter", + "squawk_server", "squawk_syntax", "structopt", "tempfile", @@ -2612,6 +2668,21 @@ dependencies = [ "xshell", ] +[[package]] +name = "squawk_server" +version = "0.0.0" +dependencies = [ + "anyhow", + "line-index", + "log", + "lsp-server", + "lsp-types", + "serde_json", + "simplelog", + "squawk_linter", + "squawk_syntax", +] + [[package]] name = "squawk_syntax" version = "0.0.0" @@ -2966,7 +3037,7 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb2d1b8f4548dbf5e1f7818512e9c406860678f29c300cdf0ebac72d1a3a1671" dependencies = [ - "crossbeam-utils", + "crossbeam-utils 0.7.2", "futures", ] @@ -2997,7 +3068,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09bc590ec4ba8ba87652da2068d150dcada2cfa2e07faae270a5e0409aa51351" dependencies = [ - "crossbeam-utils", + "crossbeam-utils 0.7.2", "futures", "lazy_static", "log", @@ -3052,7 +3123,7 @@ checksum = "df720b6581784c118f0eb4310796b12b1d242a7eb95f716a8367855325c25f89" dependencies = [ "crossbeam-deque", "crossbeam-queue", - "crossbeam-utils", + "crossbeam-utils 0.7.2", "futures", "lazy_static", "log", @@ -3067,7 +3138,7 @@ version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93044f2d313c95ff1cb7809ce9a7a05735b012288a888b62d4434fd58c94f296" dependencies = [ - "crossbeam-utils", + "crossbeam-utils 0.7.2", "futures", "slab", "tokio-executor", @@ -3248,6 +3319,7 @@ dependencies = [ "form_urlencoded", "idna 1.0.3", "percent-encoding 2.3.1", + "serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e7ecfcd6..efb8ded8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,8 @@ rowan = "0.15.15" smol_str = "0.3.2" enum-iterator = "2.1.0" line-index = "0.1.2" +lsp-server = "0.7.8" +lsp-types = "0.95" serde-wasm-bindgen = "0.6.5" wasm-bindgen = "0.2.100" wasm-bindgen-test = "0.3.34" @@ -57,6 +59,7 @@ squawk_lexer = { version = "0.0.0", path = "./crates/squawk_lexer" } squawk_parser = { version = "0.0.0", path = "./crates/squawk_parser" } squawk_syntax = { version = "0.0.0", path = "./crates/squawk_syntax" } squawk_linter = { version = "0.0.0", path = "./crates/squawk_linter" } +squawk_server = { version = "0.0.0", path = "./crates/squawk_server" } [workspace.lints.clippy] collapsible_else_if = "allow" diff --git a/crates/squawk/Cargo.toml b/crates/squawk/Cargo.toml index f5310148..3991f4f6 100644 --- a/crates/squawk/Cargo.toml +++ b/crates/squawk/Cargo.toml @@ -30,11 +30,14 @@ squawk_syntax.workspace = true squawk_linter.workspace = true squawk_lexer.workspace = true squawk_github.workspace = true +squawk_server.workspace = true toml.workspace = true glob.workspace = true anyhow.workspace = true annotate-snippets.workspace = true line-index.workspace = true +lsp-server.workspace = true +lsp-types.workspace = true [dev-dependencies] insta.workspace = true diff --git a/crates/squawk/src/github.rs b/crates/squawk/src/github.rs index 2b6f9d2f..30c9e1c7 100644 --- a/crates/squawk/src/github.rs +++ b/crates/squawk/src/github.rs @@ -1,4 +1,4 @@ -use crate::Command; +use crate::UploadToGithubArgs; use crate::config::Config; use crate::reporter::{CheckReport, fmt_tty_violation}; use crate::{file_finding::find_paths, reporter::check_files}; @@ -69,7 +69,7 @@ fn create_gh_app( const COMMENT_HEADER: &str = "# Squawk Report"; pub fn check_and_comment_on_pr( - cmd: Command, + args: UploadToGithubArgs, cfg: &Config, is_stdin: bool, stdin_path: Option, @@ -78,7 +78,7 @@ pub fn check_and_comment_on_pr( pg_version: Option, assume_in_transaction: bool, ) -> Result<()> { - let Command::UploadToGithub { + let UploadToGithubArgs { paths, fail_on_violations, github_private_key, @@ -89,7 +89,7 @@ pub fn check_and_comment_on_pr( github_repo_name, github_pr_number, github_private_key_base64, - } = cmd; + } = args; let fail_on_violations = if let Some(fail_on_violations_cfg) = cfg.upload_to_github.fail_on_violations { diff --git a/crates/squawk/src/main.rs b/crates/squawk/src/main.rs index 494e02f5..63ad5881 100644 --- a/crates/squawk/src/main.rs +++ b/crates/squawk/src/main.rs @@ -21,40 +21,45 @@ use std::path::PathBuf; use std::process::{self, ExitCode}; use structopt::StructOpt; +#[derive(StructOpt, Debug)] +pub struct UploadToGithubArgs { + /// Paths to search + paths: Vec, + /// Exits with an error if violations are found + #[structopt(long)] + fail_on_violations: bool, + #[structopt(long, env = "SQUAWK_GITHUB_PRIVATE_KEY")] + github_private_key: Option, + #[structopt(long, env = "SQUAWK_GITHUB_PRIVATE_KEY_BASE64")] + github_private_key_base64: Option, + #[structopt(long, env = "SQUAWK_GITHUB_TOKEN")] + github_token: Option, + /// GitHub App Id. + #[structopt(long, env = "SQUAWK_GITHUB_APP_ID")] + github_app_id: Option, + /// GitHub Install Id. The installation that squawk is acting on. + #[structopt(long, env = "SQUAWK_GITHUB_INSTALL_ID")] + github_install_id: Option, + /// GitHub Repo Owner + /// github.com/sbdchd/squawk, sbdchd is the owner + #[structopt(long, env = "SQUAWK_GITHUB_REPO_OWNER")] + github_repo_owner: String, + /// GitHub Repo Name + /// github.com/sbdchd/squawk, squawk is the name + #[structopt(long, env = "SQUAWK_GITHUB_REPO_NAME")] + github_repo_name: String, + /// GitHub Pull Request Number + /// github.com/sbdchd/squawk/pull/10, 10 is the PR number + #[structopt(long, env = "SQUAWK_GITHUB_PR_NUMBER")] + github_pr_number: i64, +} + #[derive(StructOpt, Debug)] pub enum Command { + /// Run the language server + Server, /// Comment on a PR with Squawk's results. - UploadToGithub { - /// Paths to search - paths: Vec, - /// Exits with an error if violations are found - #[structopt(long)] - fail_on_violations: bool, - #[structopt(long, env = "SQUAWK_GITHUB_PRIVATE_KEY")] - github_private_key: Option, - #[structopt(long, env = "SQUAWK_GITHUB_PRIVATE_KEY_BASE64")] - github_private_key_base64: Option, - #[structopt(long, env = "SQUAWK_GITHUB_TOKEN")] - github_token: Option, - /// GitHub App Id. - #[structopt(long, env = "SQUAWK_GITHUB_APP_ID")] - github_app_id: Option, - /// GitHub Install Id. The installation that squawk is acting on. - #[structopt(long, env = "SQUAWK_GITHUB_INSTALL_ID")] - github_install_id: Option, - /// GitHub Repo Owner - /// github.com/sbdchd/squawk, sbdchd is the owner - #[structopt(long, env = "SQUAWK_GITHUB_REPO_OWNER")] - github_repo_owner: String, - /// GitHub Repo Name - /// github.com/sbdchd/squawk, squawk is the name - #[structopt(long, env = "SQUAWK_GITHUB_REPO_NAME")] - github_repo_name: String, - /// GitHub Pull Request Number - /// github.com/sbdchd/squawk/pull/10, 10 is the PR number - #[structopt(long, env = "SQUAWK_GITHUB_PR_NUMBER")] - github_pr_number: i64, - }, + UploadToGithub(UploadToGithubArgs), } arg_enum! { @@ -210,55 +215,64 @@ Please open an issue at https://github.com/sbdchd/squawk/issues/new with the log info!("assume in a transaction: {assume_in_transaction:?}"); let mut clap_app = Opt::clap(); - let stdout = io::stdout(); - let mut handle = stdout.lock(); - let is_stdin = !atty::is(Stream::Stdin); - let found_paths = find_paths(&opts.path_patterns, &excluded_paths).unwrap_or_else(|e| { - eprintln!("Failed to find files: {e}"); - process::exit(1); - }); - if found_paths.is_empty() && !opts.path_patterns.is_empty() { - eprintln!( - "Failed to find files for provided patterns: {:?}", - opts.path_patterns - ); - process::exit(1); - } if let Some(subcommand) = opts.cmd { - github::check_and_comment_on_pr( - subcommand, - &conf, - is_stdin, - opts.stdin_filepath, - &excluded_rules, - &excluded_paths, - pg_version, - assume_in_transaction, - ) - .context("Upload to GitHub failed")?; - } else if !found_paths.is_empty() || is_stdin { - let read_stdin = found_paths.is_empty() && is_stdin; - if let Some(kind) = opts.debug { - debug(&mut handle, &found_paths, read_stdin, &kind, opts.verbose)?; - } else { - let reporter = opts.reporter.unwrap_or(Reporter::Tty); - let exit_code = check_and_dump_files( - &mut handle, - &found_paths, - read_stdin, - opts.stdin_filepath, - &excluded_rules, - pg_version, - assume_in_transaction, - &reporter, - )?; - return Ok(exit_code); + match subcommand { + Command::Server => { + squawk_server::run_server().context("language server failed")?; + } + Command::UploadToGithub(args) => { + github::check_and_comment_on_pr( + args, + &conf, + is_stdin, + opts.stdin_filepath, + &excluded_rules, + &excluded_paths, + pg_version, + assume_in_transaction, + ) + .context("Upload to GitHub failed")?; + } } } else { - clap_app.print_long_help()?; - println!(); + let found_paths = find_paths(&opts.path_patterns, &excluded_paths).unwrap_or_else(|e| { + eprintln!("Failed to find files: {e}"); + process::exit(1); + }); + if found_paths.is_empty() && !opts.path_patterns.is_empty() { + eprintln!( + "Failed to find files for provided patterns: {:?}", + opts.path_patterns + ); + process::exit(1); + } + if !found_paths.is_empty() || is_stdin { + let stdout = io::stdout(); + let mut handle = stdout.lock(); + + let read_stdin = found_paths.is_empty() && is_stdin; + if let Some(kind) = opts.debug { + debug(&mut handle, &found_paths, read_stdin, &kind, opts.verbose)?; + } else { + let reporter = opts.reporter.unwrap_or(Reporter::Tty); + let exit_code = check_and_dump_files( + &mut handle, + &found_paths, + read_stdin, + opts.stdin_filepath, + &excluded_rules, + pg_version, + assume_in_transaction, + &reporter, + )?; + return Ok(exit_code); + } + } else { + clap_app.print_long_help()?; + println!(); + } } Ok(ExitCode::SUCCESS) } diff --git a/crates/squawk/src/server.rs b/crates/squawk/src/server.rs new file mode 100644 index 00000000..59826ea5 --- /dev/null +++ b/crates/squawk/src/server.rs @@ -0,0 +1,93 @@ +use anyhow::Result; +use log::info; +use lsp_server::{Connection, Message, Response}; +use lsp_types::{ + GotoDefinitionParams, GotoDefinitionResponse, InitializeParams, Location, OneOf, Position, + Range, ServerCapabilities, + request::{GotoDefinition, Request}, +}; + +pub fn run_server() -> Result<()> { + info!("Starting Squawk LSP server"); + + let (connection, io_threads) = Connection::stdio(); + + let server_capabilities = serde_json::to_value(&ServerCapabilities { + definition_provider: Some(OneOf::Left(true)), + ..Default::default() + }) + .unwrap(); + + info!("LSP server initalizing connection..."); + let initialization_params = connection.initialize(server_capabilities)?; + info!("LSP server initialized, entering main loop"); + + main_loop(connection, initialization_params)?; + + info!("LSP server shutting down"); + + io_threads.join()?; + Ok(()) +} + +fn main_loop(connection: Connection, params: serde_json::Value) -> Result<()> { + info!("Server main loop"); + + let init_params: InitializeParams = serde_json::from_value(params).unwrap_or_default(); + info!("Client process ID: {:?}", init_params.process_id); + let client_name = init_params.client_info.map(|x| x.name); + info!("Client name: {client_name:?}"); + + for msg in &connection.receiver { + match msg { + Message::Request(req) => { + info!("Received request: method={}, id={:?}", req.method, req.id); + + if connection.handle_shutdown(&req)? { + info!("Received shutdown request, exiting"); + return Ok(()); + } + + if req.method == GotoDefinition::METHOD { + handle_goto_definition(&connection, req)?; + continue; + } + + info!("Ignoring unhandled request: {}", req.method); + } + Message::Response(resp) => { + info!("Received response: id={:?}", resp.id); + } + Message::Notification(notif) => { + info!("Received notification: method={}", notif.method); + } + } + } + Ok(()) +} + +fn handle_goto_definition(connection: &Connection, req: lsp_server::Request) -> Result<()> { + let params: GotoDefinitionParams = serde_json::from_value(req.params)?; + + let location = Location { + uri: params + .text_document_position_params + .text_document + .uri + .clone(), + range: Range { + start: Position::new(1, 2), + end: Position::new(1, 3), + }, + }; + + let result = GotoDefinitionResponse::Scalar(location); + let resp = Response { + id: req.id, + result: Some(serde_json::to_value(&result).unwrap()), + error: None, + }; + + connection.sender.send(Message::Response(resp))?; + Ok(()) +} diff --git a/crates/squawk_server/Cargo.toml b/crates/squawk_server/Cargo.toml new file mode 100644 index 00000000..7d97b805 --- /dev/null +++ b/crates/squawk_server/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "squawk_server" +version = "0.0.0" + +authors.workspace = true +edition.workspace = true +license.workspace = true +description = "LSP server for Squawk" + +[dependencies] +anyhow.workspace = true +log.workspace = true +simplelog.workspace = true +lsp-server.workspace = true +lsp-types.workspace = true +serde_json.workspace = true +squawk_linter.workspace = true +squawk_syntax.workspace = true +line-index.workspace = true + +[lints] +workspace = true \ No newline at end of file diff --git a/crates/squawk_server/README.md b/crates/squawk_server/README.md new file mode 100644 index 00000000..06e22ed4 --- /dev/null +++ b/crates/squawk_server/README.md @@ -0,0 +1,3 @@ +# squawk_server + +LSP server for Squawk. diff --git a/crates/squawk_server/src/lib.rs b/crates/squawk_server/src/lib.rs new file mode 100644 index 00000000..22676a3c --- /dev/null +++ b/crates/squawk_server/src/lib.rs @@ -0,0 +1,212 @@ +use anyhow::Result; +use line_index::LineIndex; +use log::info; +use lsp_server::{Connection, Message, Notification, Response}; +use lsp_types::{ + CodeDescription, Diagnostic, DiagnosticSeverity, DidChangeTextDocumentParams, + DidOpenTextDocumentParams, GotoDefinitionParams, GotoDefinitionResponse, InitializeParams, + Location, OneOf, Position, PublishDiagnosticsParams, Range, ServerCapabilities, + TextDocumentSyncCapability, TextDocumentSyncKind, Url, + notification::{ + DidChangeTextDocument, DidOpenTextDocument, Notification as _, PublishDiagnostics, + }, + request::{GotoDefinition, Request}, +}; +use squawk_linter::Linter; +use squawk_syntax::{Parse, SourceFile}; + +pub fn run_server() -> Result<()> { + info!("Starting Squawk LSP server"); + + let (connection, io_threads) = Connection::stdio(); + + let server_capabilities = serde_json::to_value(&ServerCapabilities { + text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL)), + definition_provider: Some(OneOf::Left(true)), + ..Default::default() + }) + .unwrap(); + + info!("LSP server initializing connection..."); + let initialization_params = connection.initialize(server_capabilities)?; + info!("LSP server initialized, entering main loop"); + + main_loop(connection, initialization_params)?; + + info!("LSP server shutting down"); + + io_threads.join()?; + Ok(()) +} + +fn main_loop(connection: Connection, params: serde_json::Value) -> Result<()> { + info!("Server main loop"); + + let init_params: InitializeParams = serde_json::from_value(params).unwrap_or_default(); + info!("Client process ID: {:?}", init_params.process_id); + let client_name = init_params.client_info.map(|x| x.name); + info!("Client name: {client_name:?}"); + + for msg in &connection.receiver { + match msg { + Message::Request(req) => { + info!("Received request: method={}, id={:?}", req.method, req.id); + + if connection.handle_shutdown(&req)? { + info!("Received shutdown request, exiting"); + return Ok(()); + } + + if req.method == GotoDefinition::METHOD { + handle_goto_definition(&connection, req)?; + continue; + } + + info!("Ignoring unhandled request: {}", req.method); + } + Message::Response(resp) => { + info!("Received response: id={:?}", resp.id); + } + Message::Notification(notif) => { + info!("Received notification: method={}", notif.method); + + if notif.method == DidOpenTextDocument::METHOD { + handle_did_open(&connection, notif)?; + } else if notif.method == DidChangeTextDocument::METHOD { + handle_did_change(&connection, notif)?; + } + } + } + } + Ok(()) +} + +fn handle_goto_definition(connection: &Connection, req: lsp_server::Request) -> Result<()> { + let params: GotoDefinitionParams = serde_json::from_value(req.params)?; + + let location = Location { + uri: params.text_document_position_params.text_document.uri, + range: Range { + start: Position::new(1, 2), + end: Position::new(1, 3), + }, + }; + + let result = GotoDefinitionResponse::Scalar(location); + let resp = Response { + id: req.id, + result: Some(serde_json::to_value(&result).unwrap()), + error: None, + }; + + connection.sender.send(Message::Response(resp))?; + Ok(()) +} + +fn handle_did_open(connection: &Connection, notif: lsp_server::Notification) -> Result<()> { + let params: DidOpenTextDocumentParams = serde_json::from_value(notif.params)?; + let uri = params.text_document.uri; + let content = params.text_document.text; + let version = params.text_document.version; + + lint(connection, uri, &content, version)?; + + Ok(()) +} + +fn handle_did_change(connection: &Connection, notif: lsp_server::Notification) -> Result<()> { + let params: DidChangeTextDocumentParams = serde_json::from_value(notif.params)?; + let uri = params.text_document.uri; + let version = params.text_document.version; + + if let Some(change) = params.content_changes.last() { + lint(connection, uri, &change.text, version)?; + } + + Ok(()) +} + +fn lint(connection: &Connection, uri: lsp_types::Url, content: &str, version: i32) -> Result<()> { + let parse: Parse = SourceFile::parse(&content); + let parse_errors = parse.errors(); + let mut linter = Linter::with_all_rules(); + let violations = linter.lint(parse, &content); + let line_index = LineIndex::new(&content); + + let mut diagnostics = vec![]; + + for error in parse_errors { + let range_start = error.range().start(); + let range_end = error.range().end(); + let start_line_col = line_index.line_col(range_start); + let mut end_line_col = line_index.line_col(range_end); + + if start_line_col.line == end_line_col.line && start_line_col.col == end_line_col.col { + end_line_col.col += 1; + } + + let diagnostic = Diagnostic { + range: Range { + start: Position::new(start_line_col.line, start_line_col.col), + end: Position::new(end_line_col.line, end_line_col.col), + }, + severity: Some(DiagnosticSeverity::ERROR), + code: Some(lsp_types::NumberOrString::String( + "syntax-error".to_string(), + )), + code_description: Some(CodeDescription { + href: Url::parse("https://squawkhq.com/docs/syntax-error").unwrap(), + }), + source: Some("squawk".to_string()), + message: error.message().to_string(), + ..Default::default() + }; + diagnostics.push(diagnostic); + } + + for violation in violations { + let range_start = violation.text_range.start(); + let range_end = violation.text_range.end(); + let start_line_col = line_index.line_col(range_start); + let mut end_line_col = line_index.line_col(range_end); + + if start_line_col.line == end_line_col.line && start_line_col.col == end_line_col.col { + end_line_col.col += 1; + } + + let diagnostic = Diagnostic { + range: Range { + start: Position::new(start_line_col.line, start_line_col.col), + end: Position::new(end_line_col.line, end_line_col.col), + }, + severity: Some(DiagnosticSeverity::WARNING), + code: Some(lsp_types::NumberOrString::String( + violation.code.to_string(), + )), + code_description: Some(CodeDescription { + href: Url::parse(&format!("https://squawkhq.com/docs/{}", violation.code)).unwrap(), + }), + source: Some("squawk".to_string()), + message: violation.message, + ..Default::default() + }; + diagnostics.push(diagnostic); + } + + let publish_params = PublishDiagnosticsParams { + uri: uri, + diagnostics, + version: Some(version), + }; + + let notification = Notification { + method: PublishDiagnostics::METHOD.to_owned(), + params: serde_json::to_value(publish_params)?, + }; + + connection + .sender + .send(Message::Notification(notification))?; + + Ok(()) +} diff --git a/crates/squawk_syntax/src/parsing.rs b/crates/squawk_syntax/src/parsing.rs index 01bc0ba9..8d0ec438 100644 --- a/crates/squawk_syntax/src/parsing.rs +++ b/crates/squawk_syntax/src/parsing.rs @@ -28,9 +28,6 @@ use rowan::{GreenNode, TextRange}; use crate::{syntax_error::SyntaxError, syntax_node::SyntaxTreeBuilder}; -// 1. lex input into vec of tokens -// 2. run the parser over the tokens generating an array of events -// 3. intersperse trivia TODO pub(crate) fn parse_text(text: &str) -> (GreenNode, Vec) { let lexed = squawk_parser::LexedStr::new(text); let parser_input = lexed.to_input(); @@ -39,43 +36,6 @@ pub(crate) fn parse_text(text: &str) -> (GreenNode, Vec) { (node, errors) } -// 5. check for lexer errors and add them to the parser errors -// 6. return -// (node, errors) - -// 7. then some other function passes this stuff to SyntaxNode::new_root -// pub fn parse(text: &str, edition: Edition) -> Parse { -// let _p = tracing::info_span!("SourceFile::parse").entered(); -// let (green, errors) = parsing::parse_text(text, edition); -// let root = SyntaxNode::new_root(green.clone()); - -// assert_eq!(root.kind(), SyntaxKind::SOURCE_FILE); -// Parse::new(green, errors) -// } - -// 8. rust analyzer has another validation layer that traverses the syntax tree to cover issues not caught by the lexer or parser - -// ast nodes wrap -// - rowan::syntax nodes -// - rowan::green nodes - -// -// they do some stuff to support having proper types for it -// -// also have can_cast and cast methods to support casting between the two -// -// #[derive(Debug, Clone, PartialEq, Eq, Hash)] -// pub struct IfExpr { -// pub(crate) syntax: SyntaxNode, -// } -// impl ast::HasAttrs for IfExpr {} -// impl IfExpr { -// #[inline] -// pub fn else_token(&self) -> Option { support::token(&self.syntax, T![else]) } -// #[inline] -// pub fn if_token(&self) -> Option { support::token(&self.syntax, T![if]) } -// } - pub(crate) fn build_tree( lexed: squawk_parser::LexedStr<'_>, parser_output: squawk_parser::Output, diff --git a/squawk-vscode/README.md b/squawk-vscode/README.md index ae80e13e..c34d14e4 100644 --- a/squawk-vscode/README.md +++ b/squawk-vscode/README.md @@ -1,3 +1,8 @@ # squawk-vscode > Visual Studio Code support for Squawk + +## dev + +Make sure you're on a vscode version >= the one defined in the `package.json`, +otherwise the extension development host won't load the extension. diff --git a/squawk-vscode/package.json b/squawk-vscode/package.json index bc113e85..1a8380b3 100644 --- a/squawk-vscode/package.json +++ b/squawk-vscode/package.json @@ -33,8 +33,31 @@ "contributes": { "commands": [ { - "command": "squawk.helloWorld", - "title": "Hello World" + "command": "squawk.serverVersion", + "title": "Show Server Version", + "category": "Squawk" + } + ], + "languages": [ + { + "id": "sql", + "extensions": [ + ".sql" + ], + "aliases": [ + "SQL", + "sql" + ] + }, + { + "id": "postgres", + "extensions": [ + ".psql" + ], + "aliases": [ + "PostgreSQL", + "postgres" + ] } ] }, @@ -72,5 +95,8 @@ "volta": { "node": "18.7.0", "pnpm": "8.15.8" + }, + "dependencies": { + "vscode-languageclient": "^9.0.1" } } diff --git a/squawk-vscode/pnpm-lock.yaml b/squawk-vscode/pnpm-lock.yaml index 76748ba5..62f39d30 100644 --- a/squawk-vscode/pnpm-lock.yaml +++ b/squawk-vscode/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + vscode-languageclient: + specifier: ^9.0.1 + version: 9.0.1 devDependencies: '@types/mocha': specifier: ^10.0.10 @@ -2352,6 +2356,20 @@ packages: resolution: {integrity: sha512-gjb0ARm9qlcBAonU4zPwkl9ecKkas+tC2CGwFfptTCWWIVTWY1YUbT2zZKsOAF1jR/tNxxyLwwG0cb42XlYcTg==} engines: {node: '>=4'} + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageclient@9.0.1: + resolution: {integrity: sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA==} + engines: {vscode: ^1.82.0} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} @@ -5080,6 +5098,21 @@ snapshots: version-range@4.14.0: {} + vscode-jsonrpc@8.2.0: {} + + vscode-languageclient@9.0.1: + dependencies: + minimatch: 5.1.6 + semver: 7.7.2 + vscode-languageserver-protocol: 3.17.5 + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-types@3.17.5: {} + whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 diff --git a/squawk-vscode/src/extension.ts b/squawk-vscode/src/extension.ts index 3532e337..b1a3cb17 100644 --- a/squawk-vscode/src/extension.ts +++ b/squawk-vscode/src/extension.ts @@ -1,28 +1,83 @@ -// The module 'vscode' contains the VS Code extensibility API -// Import the module and reference it with the alias vscode in your code below import * as vscode from "vscode" +import { execFileSync } from "child_process" +import { + LanguageClient, + LanguageClientOptions, + Executable, + ServerOptions, +} from "vscode-languageclient/node" -// This method is called when your extension is activated -// Your extension is activated the very first time the command is executed -export function activate(context: vscode.ExtensionContext) { - // Use the console to output diagnostic information (console.log) and errors (console.error) - // This line of code will only be executed once when your extension is activated - console.log('Congratulations, your extension "squawk" is now active!') - - // The command has been defined in the package.json file - // Now provide the implementation of the command with registerCommand - // The commandId parameter must match the command field in package.json - const disposable = vscode.commands.registerCommand( - "squawk.helloWorld", +let client: LanguageClient | undefined + +export async function activate(context: vscode.ExtensionContext) { + console.log("Squawk activate") + + const serverVersionCommand = vscode.commands.registerCommand( + "squawk.serverVersion", () => { - // The code you place here will be executed every time your command is executed - // Display a message box to the user - vscode.window.showInformationMessage("Hello World from squawk!") + try { + const serverPath = getSquawkPath(context) + const stdout = execFileSync(serverPath.path, ["--version"], { + encoding: "utf8", + }) + const version = stdout.trim() + vscode.window.showInformationMessage( + `Squawk Server Version: ${version}`, + ) + return version + } catch (error) { + vscode.window.showErrorMessage(`Failed to get server version: ${error}`) + } }, ) + context.subscriptions.push(serverVersionCommand) + + await startServer(context) +} + +export async function deactivate() { + await client?.stop() +} - context.subscriptions.push(disposable) +function getSquawkPath(context: vscode.ExtensionContext): vscode.Uri { + const ext = process.platform === "win32" ? ".exe" : "" + return vscode.Uri.joinPath(context.extensionUri, "server", `squawk${ext}`) } -// This method is called when your extension is deactivated -export function deactivate() {} +async function startServer(context: vscode.ExtensionContext) { + console.log("starting squawk server") + + const squawkPath = getSquawkPath(context) + const hasBinary = await vscode.workspace.fs.stat(squawkPath).then( + () => true, + () => false, + ) + if (!hasBinary) { + const errorMsg = `Squawk binary not found at: ${squawkPath.path}` + console.error(errorMsg) + vscode.window.showErrorMessage(errorMsg) + return + } + console.log(`Found Squawk binary at: ${squawkPath}`) + + const serverExecutable: Executable = { + command: squawkPath.path, + args: ["server", "--verbose"], + } + const serverOptions: ServerOptions = serverExecutable + const clientOptions: LanguageClientOptions = { + documentSelector: [ + { scheme: "file", language: "sql" }, + { scheme: "file", language: "postgres" }, + ], + outputChannel: vscode.window.createOutputChannel("Squawk Language Server"), + } + client = new LanguageClient( + "squawk", + "Squawk Language Server", + serverOptions, + clientOptions, + ) + + client.start() +} From a3dd856bdd4629b31ccb570d351bb90322a9fa53 Mon Sep 17 00:00:00 2001 From: Steve Dignam Date: Sun, 29 Jun 2025 22:33:41 -0400 Subject: [PATCH 2/3] fix --- crates/squawk_server/src/lib.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/squawk_server/src/lib.rs b/crates/squawk_server/src/lib.rs index 22676a3c..a3203b71 100644 --- a/crates/squawk_server/src/lib.rs +++ b/crates/squawk_server/src/lib.rs @@ -127,11 +127,11 @@ fn handle_did_change(connection: &Connection, notif: lsp_server::Notification) - } fn lint(connection: &Connection, uri: lsp_types::Url, content: &str, version: i32) -> Result<()> { - let parse: Parse = SourceFile::parse(&content); + let parse: Parse = SourceFile::parse(content); let parse_errors = parse.errors(); let mut linter = Linter::with_all_rules(); - let violations = linter.lint(parse, &content); - let line_index = LineIndex::new(&content); + let violations = linter.lint(parse, content); + let line_index = LineIndex::new(content); let mut diagnostics = vec![]; @@ -194,7 +194,7 @@ fn lint(connection: &Connection, uri: lsp_types::Url, content: &str, version: i3 } let publish_params = PublishDiagnosticsParams { - uri: uri, + uri, diagnostics, version: Some(version), }; From 1d79bdc50cb0b26fc8ee27ee3648fd8090dd65d8 Mon Sep 17 00:00:00 2001 From: Steve Dignam Date: Sun, 29 Jun 2025 22:34:52 -0400 Subject: [PATCH 3/3] remove dead file --- crates/squawk/src/server.rs | 93 ------------------------------------- 1 file changed, 93 deletions(-) delete mode 100644 crates/squawk/src/server.rs diff --git a/crates/squawk/src/server.rs b/crates/squawk/src/server.rs deleted file mode 100644 index 59826ea5..00000000 --- a/crates/squawk/src/server.rs +++ /dev/null @@ -1,93 +0,0 @@ -use anyhow::Result; -use log::info; -use lsp_server::{Connection, Message, Response}; -use lsp_types::{ - GotoDefinitionParams, GotoDefinitionResponse, InitializeParams, Location, OneOf, Position, - Range, ServerCapabilities, - request::{GotoDefinition, Request}, -}; - -pub fn run_server() -> Result<()> { - info!("Starting Squawk LSP server"); - - let (connection, io_threads) = Connection::stdio(); - - let server_capabilities = serde_json::to_value(&ServerCapabilities { - definition_provider: Some(OneOf::Left(true)), - ..Default::default() - }) - .unwrap(); - - info!("LSP server initalizing connection..."); - let initialization_params = connection.initialize(server_capabilities)?; - info!("LSP server initialized, entering main loop"); - - main_loop(connection, initialization_params)?; - - info!("LSP server shutting down"); - - io_threads.join()?; - Ok(()) -} - -fn main_loop(connection: Connection, params: serde_json::Value) -> Result<()> { - info!("Server main loop"); - - let init_params: InitializeParams = serde_json::from_value(params).unwrap_or_default(); - info!("Client process ID: {:?}", init_params.process_id); - let client_name = init_params.client_info.map(|x| x.name); - info!("Client name: {client_name:?}"); - - for msg in &connection.receiver { - match msg { - Message::Request(req) => { - info!("Received request: method={}, id={:?}", req.method, req.id); - - if connection.handle_shutdown(&req)? { - info!("Received shutdown request, exiting"); - return Ok(()); - } - - if req.method == GotoDefinition::METHOD { - handle_goto_definition(&connection, req)?; - continue; - } - - info!("Ignoring unhandled request: {}", req.method); - } - Message::Response(resp) => { - info!("Received response: id={:?}", resp.id); - } - Message::Notification(notif) => { - info!("Received notification: method={}", notif.method); - } - } - } - Ok(()) -} - -fn handle_goto_definition(connection: &Connection, req: lsp_server::Request) -> Result<()> { - let params: GotoDefinitionParams = serde_json::from_value(req.params)?; - - let location = Location { - uri: params - .text_document_position_params - .text_document - .uri - .clone(), - range: Range { - start: Position::new(1, 2), - end: Position::new(1, 3), - }, - }; - - let result = GotoDefinitionResponse::Scalar(location); - let resp = Response { - id: req.id, - result: Some(serde_json::to_value(&result).unwrap()), - error: None, - }; - - connection.sender.send(Message::Response(resp))?; - Ok(()) -}