From 9f5ab7d31f13e75ada1467cf30fbbd1f83e92fb7 Mon Sep 17 00:00:00 2001 From: Steve Dignam Date: Mon, 29 Dec 2025 13:02:28 -0500 Subject: [PATCH] playground: inlay hints, doc symbols, hover, goto def/refs, actions --- Cargo.lock | 2 + crates/squawk_wasm/Cargo.toml | 2 + crates/squawk_wasm/src/lib.rs | 265 ++++++++++++++++++++++++++++++++++ playground/package.json | 5 +- playground/src/App.tsx | 42 ++++++ playground/src/providers.tsx | 213 +++++++++++++++++++++++++++ playground/src/squawk.tsx | 85 ++++++++++- 7 files changed, 610 insertions(+), 4 deletions(-) create mode 100644 playground/src/providers.tsx diff --git a/Cargo.lock b/Cargo.lock index 209e4352..372b6058 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1967,8 +1967,10 @@ dependencies = [ "console_log", "line-index", "log", + "rowan", "serde", "serde-wasm-bindgen", + "squawk-ide", "squawk-lexer", "squawk-linter", "squawk-syntax", diff --git a/crates/squawk_wasm/Cargo.toml b/crates/squawk_wasm/Cargo.toml index e1c5bc9b..56803701 100644 --- a/crates/squawk_wasm/Cargo.toml +++ b/crates/squawk_wasm/Cargo.toml @@ -21,6 +21,8 @@ default = ["console_error_panic_hook"] squawk-syntax.workspace = true squawk-linter.workspace = true squawk-lexer.workspace = true +squawk-ide.workspace = true +rowan.workspace = true wasm-bindgen.workspace = true serde-wasm-bindgen.workspace = true diff --git a/crates/squawk_wasm/src/lib.rs b/crates/squawk_wasm/src/lib.rs index a20b5052..fd396c23 100644 --- a/crates/squawk_wasm/src/lib.rs +++ b/crates/squawk_wasm/src/lib.rs @@ -185,3 +185,268 @@ pub fn lint(text: String) -> Result { fn into_error(err: E) -> Error { Error::new(&err.to_string()) } + +#[wasm_bindgen] +pub fn goto_definition(content: String, line: u32, col: u32) -> Result { + let parse = squawk_syntax::SourceFile::parse(&content); + let line_index = LineIndex::new(&content); + let offset = position_to_offset(&line_index, line, col)?; + let result = squawk_ide::goto_definition::goto_definition(parse.tree(), offset); + + let response = result.map(|range| { + let start = line_index.line_col(range.start()); + let end = line_index.line_col(range.end()); + let start_wide = line_index + .to_wide(line_index::WideEncoding::Utf16, start) + .unwrap(); + let end_wide = line_index + .to_wide(line_index::WideEncoding::Utf16, end) + .unwrap(); + + LocationRange { + start_line: start_wide.line, + start_column: start_wide.col, + end_line: end_wide.line, + end_column: end_wide.col, + } + }); + + serde_wasm_bindgen::to_value(&response).map_err(into_error) +} + +#[wasm_bindgen] +pub fn hover(content: String, line: u32, col: u32) -> Result { + let parse = squawk_syntax::SourceFile::parse(&content); + let line_index = LineIndex::new(&content); + let offset = position_to_offset(&line_index, line, col)?; + let result = squawk_ide::hover::hover(&parse.tree(), offset); + + serde_wasm_bindgen::to_value(&result).map_err(into_error) +} + +#[wasm_bindgen] +pub fn find_references(content: String, line: u32, col: u32) -> Result { + let parse = squawk_syntax::SourceFile::parse(&content); + let line_index = LineIndex::new(&content); + let offset = position_to_offset(&line_index, line, col)?; + let references = squawk_ide::find_references::find_references(&parse.tree(), offset); + + let locations: Vec = references + .iter() + .map(|range| { + let start = line_index.line_col(range.start()); + let end = line_index.line_col(range.end()); + let start_wide = line_index + .to_wide(line_index::WideEncoding::Utf16, start) + .unwrap(); + let end_wide = line_index + .to_wide(line_index::WideEncoding::Utf16, end) + .unwrap(); + + LocationRange { + start_line: start_wide.line, + start_column: start_wide.col, + end_line: end_wide.line, + end_column: end_wide.col, + } + }) + .collect(); + + serde_wasm_bindgen::to_value(&locations).map_err(into_error) +} + +#[wasm_bindgen] +pub fn document_symbols(content: String) -> Result { + let parse = squawk_syntax::SourceFile::parse(&content); + let line_index = LineIndex::new(&content); + let symbols = squawk_ide::document_symbols::document_symbols(&parse.tree()); + + let converted: Vec = symbols + .into_iter() + .map(|s| convert_document_symbol(&line_index, s)) + .collect(); + + serde_wasm_bindgen::to_value(&converted).map_err(into_error) +} + +#[wasm_bindgen] +pub fn code_actions(content: String, line: u32, col: u32) -> Result { + let parse = squawk_syntax::SourceFile::parse(&content); + let line_index = LineIndex::new(&content); + let offset = position_to_offset(&line_index, line, col)?; + let actions = squawk_ide::code_actions::code_actions(parse.tree(), offset); + + let converted = actions.map(|actions| { + actions + .into_iter() + .map(|action| { + let edits = action + .edits + .into_iter() + .map(|edit| { + let start_pos = line_index.line_col(edit.text_range.start()); + let end_pos = line_index.line_col(edit.text_range.end()); + let start_wide = line_index + .to_wide(line_index::WideEncoding::Utf16, start_pos) + .unwrap(); + let end_wide = line_index + .to_wide(line_index::WideEncoding::Utf16, end_pos) + .unwrap(); + + TextEdit { + start_line_number: start_wide.line, + start_column: start_wide.col, + end_line_number: end_wide.line, + end_column: end_wide.col, + text: edit.text.unwrap_or_default(), + } + }) + .collect(); + + WasmCodeAction { + title: action.title, + edits, + kind: match action.kind { + squawk_ide::code_actions::ActionKind::QuickFix => "quickfix", + squawk_ide::code_actions::ActionKind::RefactorRewrite => "refactor.rewrite", + } + .to_string(), + } + }) + .collect::>() + }); + + serde_wasm_bindgen::to_value(&converted).map_err(into_error) +} + +fn position_to_offset( + line_index: &LineIndex, + line: u32, + col: u32, +) -> Result { + let wide_pos = line_index::WideLineCol { line, col }; + + let pos = line_index + .to_utf8(line_index::WideEncoding::Utf16, wide_pos) + .ok_or_else(|| Error::new("Invalid position"))?; + + line_index + .offset(pos) + .ok_or_else(|| Error::new("Invalid position offset")) +} + +#[derive(Serialize)] +struct LocationRange { + start_line: u32, + start_column: u32, + end_line: u32, + end_column: u32, +} + +#[derive(Serialize)] +struct WasmCodeAction { + title: String, + edits: Vec, + kind: String, +} + +#[derive(Serialize)] +struct WasmDocumentSymbol { + name: String, + detail: Option, + kind: String, + start_line: u32, + start_column: u32, + end_line: u32, + end_column: u32, + selection_start_line: u32, + selection_start_column: u32, + selection_end_line: u32, + selection_end_column: u32, + children: Vec, +} + +fn convert_document_symbol( + line_index: &LineIndex, + symbol: squawk_ide::document_symbols::DocumentSymbol, +) -> WasmDocumentSymbol { + let full_start = line_index.line_col(symbol.full_range.start()); + let full_end = line_index.line_col(symbol.full_range.end()); + let full_start_wide = line_index + .to_wide(line_index::WideEncoding::Utf16, full_start) + .unwrap(); + let full_end_wide = line_index + .to_wide(line_index::WideEncoding::Utf16, full_end) + .unwrap(); + + let focus_start = line_index.line_col(symbol.focus_range.start()); + let focus_end = line_index.line_col(symbol.focus_range.end()); + let focus_start_wide = line_index + .to_wide(line_index::WideEncoding::Utf16, focus_start) + .unwrap(); + let focus_end_wide = line_index + .to_wide(line_index::WideEncoding::Utf16, focus_end) + .unwrap(); + + WasmDocumentSymbol { + name: symbol.name, + detail: symbol.detail, + kind: match symbol.kind { + squawk_ide::document_symbols::DocumentSymbolKind::Table => "table", + squawk_ide::document_symbols::DocumentSymbolKind::Function => "function", + squawk_ide::document_symbols::DocumentSymbolKind::Column => "column", + } + .to_string(), + start_line: full_start_wide.line, + start_column: full_start_wide.col, + end_line: full_end_wide.line, + end_column: full_end_wide.col, + selection_start_line: focus_start_wide.line, + selection_start_column: focus_start_wide.col, + selection_end_line: focus_end_wide.line, + selection_end_column: focus_end_wide.col, + children: symbol + .children + .into_iter() + .map(|child| convert_document_symbol(line_index, child)) + .collect(), + } +} + +#[wasm_bindgen] +pub fn inlay_hints(content: String) -> Result { + let parse = squawk_syntax::SourceFile::parse(&content); + let line_index = LineIndex::new(&content); + let hints = squawk_ide::inlay_hints::inlay_hints(&parse.tree()); + + let converted: Vec = hints + .into_iter() + .map(|hint| { + let position = line_index.line_col(hint.position); + let position_wide = line_index + .to_wide(line_index::WideEncoding::Utf16, position) + .unwrap(); + + WasmInlayHint { + line: position_wide.line, + column: position_wide.col, + label: hint.label, + kind: match hint.kind { + squawk_ide::inlay_hints::InlayHintKind::Type => "type", + squawk_ide::inlay_hints::InlayHintKind::Parameter => "parameter", + } + .to_string(), + } + }) + .collect(); + + serde_wasm_bindgen::to_value(&converted).map_err(into_error) +} + +#[derive(Serialize)] +struct WasmInlayHint { + line: u32, + column: u32, + label: String, + kind: String, +} diff --git a/playground/package.json b/playground/package.json index af59479c..a5cd5c5a 100644 --- a/playground/package.json +++ b/playground/package.json @@ -4,8 +4,9 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", - "build": "wasm-pack build --target web ../crates/squawk_wasm --out-dir ../../playground/src/pkg && vite build", + "dev": "npm run build:wasm && vite", + "build": "npm run build:wasm && vite build", + "build:wasm": "wasm-pack build --target web ../crates/squawk_wasm --out-dir ../../playground/src/pkg", "deploy": "netlify deploy --prod --dir dist", "lint": "eslint .", "preview": "vite preview" diff --git a/playground/src/App.tsx b/playground/src/App.tsx index a53752f8..c4fa501c 100644 --- a/playground/src/App.tsx +++ b/playground/src/App.tsx @@ -13,6 +13,13 @@ import { decompress, decompressFromEncodedURIComponent, } from "lz-string" +import { + provideInlayHints, + provideHover, + provideDefinition, + provideReferences, + provideDocumentSymbols, +} from "./providers" const modes = ["Lint", "Syntax Tree", "Tokens"] as const const STORAGE_KEY = "playground-history-v1" @@ -424,6 +431,36 @@ function Editor({ }, ) + const hoverProvider = monaco.languages.registerHoverProvider("pgsql", { + provideHover, + }) + + const definitionProvider = monaco.languages.registerDefinitionProvider( + "pgsql", + { + provideDefinition, + }, + ) + + const referencesProvider = monaco.languages.registerReferenceProvider( + "pgsql", + { + provideReferences, + }, + ) + + const documentSymbolProvider = + monaco.languages.registerDocumentSymbolProvider("pgsql", { + provideDocumentSymbols, + }) + + const inlayHintsProvider = monaco.languages.registerInlayHintsProvider( + "pgsql", + { + provideInlayHints, + }, + ) + editor.onDidChangeModelContent(() => { onChangeText(editor.getValue()) }) @@ -434,6 +471,11 @@ function Editor({ return () => { editorRef.current = null codeActionProvider.dispose() + hoverProvider.dispose() + definitionProvider.dispose() + referencesProvider.dispose() + documentSymbolProvider.dispose() + inlayHintsProvider.dispose() editor?.dispose() tokenProvider.dispose() } diff --git a/playground/src/providers.tsx b/playground/src/providers.tsx new file mode 100644 index 00000000..2a2fd92b --- /dev/null +++ b/playground/src/providers.tsx @@ -0,0 +1,213 @@ +import * as monaco from "monaco-editor" +import { + code_actions, + document_symbols, + find_references, + goto_definition, + hover, + inlay_hints, + DocumentSymbol, +} from "./squawk" + +export async function provideInlayHints( + model: monaco.editor.ITextModel, +): Promise { + const content = model.getValue() + if (!content) return { hints: [], dispose: () => {} } + + try { + const wasmHints = inlay_hints(content) + + const hints = wasmHints.map((hint) => ({ + label: hint.label, + position: { + lineNumber: hint.line + 1, + column: hint.column + 1, + }, + kind: + hint.kind === "type" + ? monaco.languages.InlayHintKind.Type + : monaco.languages.InlayHintKind.Parameter, + })) + + return { hints, dispose: () => {} } + } catch (e) { + console.error("Error in provideInlayHints:", e) + return { hints: [], dispose: () => {} } + } +} + +export async function provideDocumentSymbols( + model: monaco.editor.ITextModel, +): Promise { + const content = model.getValue() + if (!content) return [] + + try { + const symbols = document_symbols(content) + + return symbols.map((symbol) => convertDocumentSymbol(symbol)) + } catch (e) { + console.error("Error in provideDocumentSymbols:", e) + return [] + } +} + +function convertDocumentSymbol( + symbol: DocumentSymbol, +): monaco.languages.DocumentSymbol { + return { + name: symbol.name, + detail: symbol.detail || "", + kind: convertSymbolKind(symbol.kind), + range: { + startLineNumber: symbol.start_line + 1, + startColumn: symbol.start_column + 1, + endLineNumber: symbol.end_line + 1, + endColumn: symbol.end_column + 1, + }, + selectionRange: { + startLineNumber: symbol.selection_start_line + 1, + startColumn: symbol.selection_start_column + 1, + endLineNumber: symbol.selection_end_line + 1, + endColumn: symbol.selection_end_column + 1, + }, + children: symbol.children.map((child) => convertDocumentSymbol(child)), + tags: [], + } +} + +function convertSymbolKind(kind: string): monaco.languages.SymbolKind { + switch (kind) { + case "table": + return monaco.languages.SymbolKind.Class + case "function": + return monaco.languages.SymbolKind.Function + case "column": + return monaco.languages.SymbolKind.Property + default: + return monaco.languages.SymbolKind.Variable + } +} + +export async function provideCodeActions( + model: monaco.editor.ITextModel, + position: monaco.Position, +): Promise { + const content = model.getValue() + if (!content) return [] + + try { + const actions = code_actions( + content, + position.lineNumber - 1, + position.column - 1, + ) + + if (!actions) return [] + + return actions.map((action) => ({ + title: action.title, + kind: action.kind, + edit: { + edits: action.edits.map((edit) => ({ + resource: model.uri, + versionId: model.getVersionId(), + textEdit: { + range: { + startLineNumber: edit.start_line_number + 1, + startColumn: edit.start_column + 1, + endLineNumber: edit.end_line_number + 1, + endColumn: edit.end_column + 1, + }, + text: edit.text, + }, + })), + }, + })) + } catch (e) { + console.error("Error in provideCodeActions:", e) + return [] + } +} + +export async function provideHover( + model: monaco.editor.ITextModel, + position: monaco.Position, +): Promise { + const content = model.getValue() + if (!content) return null + + try { + const result = hover(content, position.lineNumber - 1, position.column - 1) + + if (!result) return null + + return { + contents: [{ value: result }], + } + } catch (e) { + console.error("Error in provideHover:", e) + return null + } +} + +export async function provideDefinition( + model: monaco.editor.ITextModel, + position: monaco.Position, +): Promise { + const content = model.getValue() + if (!content) return null + + try { + const result = goto_definition( + content, + position.lineNumber - 1, + position.column - 1, + ) + + if (!result) return null + + return { + uri: model.uri, + range: { + startLineNumber: result.start_line + 1, + startColumn: result.start_column + 1, + endLineNumber: result.end_line + 1, + endColumn: result.end_column + 1, + }, + } + } catch (e) { + console.error("Error in provideDefinition:", e) + return null + } +} + +export async function provideReferences( + model: monaco.editor.ITextModel, + position: monaco.Position, +): Promise { + const content = model.getValue() + if (!content) return [] + + try { + const results = find_references( + content, + position.lineNumber - 1, + position.column - 1, + ) + + return results.map((result) => ({ + uri: model.uri, + range: { + startLineNumber: result.start_line + 1, + startColumn: result.start_column + 1, + endLineNumber: result.end_line + 1, + endColumn: result.end_column + 1, + }, + })) + } catch (e) { + console.error("Error in provideReferences:", e) + return [] + } +} diff --git a/playground/src/squawk.tsx b/playground/src/squawk.tsx index 28a04464..cfd4a1f3 100644 --- a/playground/src/squawk.tsx +++ b/playground/src/squawk.tsx @@ -3,6 +3,12 @@ import initWasm, { dump_cst, dump_tokens, lint as lint_, + goto_definition as goto_definition_, + hover as hover_, + find_references as find_references_, + document_symbols as document_symbols_, + code_actions as code_actions_, + inlay_hints as inlay_hints_, } from "./pkg/squawk_wasm" export type TextEdit = { @@ -32,13 +38,53 @@ export type LintError = { fix?: Fix } -function lintWithTypes(text: string): Array { +function lint(text: string): Array { return lint_(text) } +export function inlay_hints(content: string): InlayHint[] { + return inlay_hints_(content) +} + +export function code_actions( + content: string, + line: number, + column: number, +): CodeAction[] | null { + return code_actions_(content, line, column) +} + +export function document_symbols(content: string): DocumentSymbol[] { + return document_symbols_(content) +} + +export function hover( + content: string, + line: number, + column: number, +): string | null { + return hover_(content, line, column) +} + +export function goto_definition( + content: string, + line: number, + column: number, +): LocationRange | null { + return goto_definition_(content, line, column) +} + +export function find_references( + content: string, + line: number, + column: number, +): LocationRange[] { + return find_references_(content, line, column) +} + export function useErrors(text: string) { const isReady = useWasmStatus() - return isReady ? lintWithTypes(text) : [] + return isReady ? lint(text) : [] } export function useDumpCst(text: string): string { @@ -83,3 +129,38 @@ function useWasmStatus() { }, []) return isReady } + +interface LocationRange { + start_line: number + start_column: number + end_line: number + end_column: number +} + +interface CodeAction { + title: string + edits: TextEdit[] + kind: string +} + +export interface DocumentSymbol { + name: string + detail: string | null + kind: string + start_line: number + start_column: number + end_line: number + end_column: number + selection_start_line: number + selection_start_column: number + selection_end_line: number + selection_end_column: number + children: DocumentSymbol[] +} + +interface InlayHint { + line: number + column: number + label: string + kind: string +}