From 6e31952ab95377a8bd5366e0ff6be28d213b8e82 Mon Sep 17 00:00:00 2001 From: Philippe Vaillancourt Date: Fri, 26 Dec 2025 23:17:55 -0500 Subject: [PATCH 01/17] feat(godot): implement script validation and keyword highlighting Implement proper script validation in the Godot editor that uses bobbin-syntax to validate scripts and report errors with accurate line and column information. Add get_reserved_words() implementation to support basic keyword highlighting via Godot's Standard syntax highlighter. Feature-gate validation behind editor-tooling to exclude from release builds. Note: EditorSyntaxHighlighter with custom highlighting is currently broken in gdext due to virtual methods like _get_name() not being dispatched to Rust implementations. See gdext issue (to be filed). For now, basic keyword highlighting is available via the Standard highlighter which uses get_reserved_words(). --- bindings/godot/src/lib.rs | 68 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/bindings/godot/src/lib.rs b/bindings/godot/src/lib.rs index 147c3aa..a9c4266 100644 --- a/bindings/godot/src/lib.rs +++ b/bindings/godot/src/lib.rs @@ -6,6 +6,12 @@ use godot::classes::{ ScriptLanguageExtension, SceneTree, Timer, file_access::ModeFlags, resource_loader::CacheMode, script_language::ScriptNameCasing, }; + +// NOTE: EditorSyntaxHighlighter is currently broken in gdext - virtual methods +// like _get_name() are not dispatched to Rust implementations. See: +// https://github.com/godot-rust/gdext/issues/XXXX (to be filed) +// For now, we rely on get_reserved_words() in ScriptLanguageExtension which +// provides basic keyword highlighting via the Standard highlighter. use godot::meta::RawPtr; use godot::prelude::*; use std::collections::HashMap; @@ -225,7 +231,14 @@ impl IScriptLanguageExtension for BobbinLanguage { // --- Language features --- fn get_reserved_words(&self) -> PackedStringArray { - PackedStringArray::new() + let mut arr = PackedStringArray::new(); + arr.push(&GString::from("temp")); + arr.push(&GString::from("save")); + arr.push(&GString::from("set")); + arr.push(&GString::from("extern")); + arr.push(&GString::from("true")); + arr.push(&GString::from("false")); + arr } fn is_control_flow_keyword(&self, _keyword: GString) -> bool { false @@ -259,16 +272,61 @@ impl IScriptLanguageExtension for BobbinLanguage { // --- Code editing --- fn validate( &self, - _script: GString, + script: GString, _path: GString, _validate_functions: bool, _validate_errors: bool, _validate_warnings: bool, _validate_safe_lines: bool, ) -> VarDictionary { - let mut dict = VarDictionary::new(); - dict.set("valid", true); - dict + #[cfg(feature = "editor-tooling")] + { + use bobbin_syntax::{validate, LineIndex}; + + let source = script.to_string(); + let diagnostics = validate(&source); + + let mut dict = VarDictionary::new(); + // Always set all expected fields (matching GDScript's validate return) + dict.set("functions", Array::::new()); + dict.set("warnings", Array::::new()); + dict.set("safe_lines", PackedInt32Array::new()); + + if diagnostics.is_empty() { + dict.set("valid", true); + dict.set("errors", Array::::new()); + } else { + dict.set("valid", false); + let line_index = LineIndex::new(&source); + let mut errors = Array::::new(); + for diag in &diagnostics { + let mut error = VarDictionary::new(); + if let Some(label) = diag.primary_label() { + let pos = line_index.line_col(label.span.start); + let line = (pos.line + 1) as i32; + let column = (pos.column + 1) as i32; + error.set("line", line); + error.set("column", column); + } else { + error.set("line", 1i32); + error.set("column", 1i32); + } + error.set("message", GString::from(diag.message.as_str())); + errors.push(&error); + } + dict.set("errors", errors); + } + dict + } + + #[cfg(not(feature = "editor-tooling"))] + { + let _ = script; // Silence unused variable warning + let _ = path; + let mut dict = VarDictionary::new(); + dict.set("valid", true); + dict + } } fn validate_path(&self, _path: GString) -> GString { GString::new() From 7a9de7bc2a500544ef828c0bfb8d48e4a225ea50 Mon Sep 17 00:00:00 2001 From: Philippe Vaillancourt Date: Fri, 26 Dec 2025 23:20:12 -0500 Subject: [PATCH 02/17] refactor(runtime): extract syntax and diagnostics into separate crates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move language components from runtime crate into specialized crates: - Scanner, Parser, AST, Token → bobbin-syntax crate - Diagnostics module → diagnostic handling in bobbin-syntax - Resolver remains in runtime for semantic analysis - Compiler and VM updated to use external dependencies This separation allows syntax components to be used independently for tooling (LSP, editor extensions) without pulling in the full runtime. Update Cargo.toml files and build configuration to reflect new workspace structure. CONTRIBUTING.md updated with new crate locations. --- CONTRIBUTING.md | 75 ++- bindings/godot/Cargo.lock | 8 + bindings/godot/Cargo.toml | 5 + docker/build.sh | 10 +- runtime/Cargo.lock | 7 + runtime/Cargo.toml | 3 +- runtime/src/ast.rs | 68 --- runtime/src/compiler.rs | 4 +- runtime/src/diagnostic/convert.rs | 46 -- runtime/src/diagnostic/fuzzy.rs | 134 ----- runtime/src/diagnostic/mod.rs | 25 - runtime/src/diagnostic/render.rs | 188 ------ runtime/src/diagnostic/types.rs | 136 ----- runtime/src/lib.rs | 31 +- runtime/src/parser.rs | 531 ---------------- runtime/src/resolver.rs | 409 ------------- runtime/src/scanner.rs | 567 ------------------ runtime/src/token.rs | 43 -- runtime/src/vm.rs | 6 +- .../dialog/feature_showcase.bobbin | 2 +- 20 files changed, 119 insertions(+), 2179 deletions(-) delete mode 100644 runtime/src/ast.rs delete mode 100644 runtime/src/diagnostic/convert.rs delete mode 100644 runtime/src/diagnostic/fuzzy.rs delete mode 100644 runtime/src/diagnostic/mod.rs delete mode 100644 runtime/src/diagnostic/render.rs delete mode 100644 runtime/src/diagnostic/types.rs delete mode 100644 runtime/src/parser.rs delete mode 100644 runtime/src/resolver.rs delete mode 100644 runtime/src/scanner.rs delete mode 100644 runtime/src/token.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 29b7e08..79059a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,15 +47,15 @@ Artifacts are automatically copied to `bindings/godot/addons/bobbin/bin/`. ### Build Options -| Target | Debug | Release | Notes | -|--------|-------|---------|-------| -| windows | `.debug.dll` | `.dll` | Cross-compiled via mingw-w64 | -| linux | `.debug.so` | `.so` | Native Linux build | -| wasm | `.wasm` | `.wasm` | Debug only; fixed name required by gdext | -| all | All targets | All targets | Builds all platforms × all types by default | - -| Flag | Effect | -|------|--------| +| Target | Debug | Release | Notes | +| ------- | ------------ | ----------- | ------------------------------------------- | +| windows | `.debug.dll` | `.dll` | Cross-compiled via mingw-w64 | +| linux | `.debug.so` | `.so` | Native Linux build | +| wasm | `.wasm` | `.wasm` | Debug only; fixed name required by gdext | +| all | All targets | All targets | Builds all platforms × all types by default | + +| Flag | Effect | +| ------ | ------------------------------------------------------- | | `--ci` | Use optimized profiles (slower build, smaller binaries) | **Notes:** @@ -149,13 +149,66 @@ undefined name ``` +## Editor Tooling Development + +Bobbin includes an LSP server and VS Code extension for editor support. + +### LSP Server + +The language server lives in `lsp/` and provides diagnostics to any LSP-compatible editor. + +```bash +# Build and install +cargo install --path lsp + +# Or just build for development +cd lsp && cargo build +``` + +### VS Code Extension + +The extension lives in `editors/vscode/`. + +**Setup:** + +```bash +cd editors/vscode +npm install +npm run compile +``` + +**Running (choose one):** + +1. **Extension Development Host** — Press `F5` with the extension folder open, or select "Run Bobbin Extension" from the debug dropdown at the repo root. + +2. **Install in your VS Code** — Link the extension into your extensions folder: + + **Windows (CMD):** + + ```cmd + mklink /J "%USERPROFILE%\.vscode\extensions\bobbin-vscode" "C:\path\to\bobbin\editors\vscode" + ``` + + **macOS/Linux:** + + ```bash + ln -s /path/to/bobbin/editors/vscode ~/.vscode/extensions/bobbin-vscode + ``` + + Then reload VS Code (`Ctrl+Shift+P` → "Reload Window"). + +**After changes:** + +- TypeScript changes: `npm run compile` then reload VS Code +- LSP changes: `cargo install --path lsp` then reload VS Code + ## Releasing ### Godot Addon Releases are automated via GitHub Actions. There are two ways to trigger a release: -**Option 1: Tag push (recommended)** +#### Option 1: Tag push (recommended) ```bash # Update version in bindings/godot/Cargo.toml, then: @@ -163,7 +216,7 @@ git tag godot-addon-v1.0.0 git push origin godot-addon-v1.0.0 ``` -**Option 2: Manual dispatch** +#### Option 2: Manual dispatch 1. Go to Actions → "Release Godot Addon" 2. Click "Run workflow" diff --git a/bindings/godot/Cargo.lock b/bindings/godot/Cargo.lock index 814f634..6823887 100644 --- a/bindings/godot/Cargo.lock +++ b/bindings/godot/Cargo.lock @@ -50,12 +50,20 @@ name = "bobbin-godot" version = "0.1.0" dependencies = [ "bobbin-runtime", + "bobbin-syntax", "godot", ] [[package]] name = "bobbin-runtime" version = "0.1.0" +dependencies = [ + "bobbin-syntax", +] + +[[package]] +name = "bobbin-syntax" +version = "0.1.0" dependencies = [ "ariadne", "strsim", diff --git a/bindings/godot/Cargo.toml b/bindings/godot/Cargo.toml index a455338..3cc0773 100644 --- a/bindings/godot/Cargo.toml +++ b/bindings/godot/Cargo.toml @@ -6,8 +6,13 @@ edition = "2024" [lib] crate-type = ["cdylib"] +[features] +default = [] +editor-tooling = ["dep:bobbin-syntax"] + [dependencies] bobbin-runtime = { path = "../../runtime" } +bobbin-syntax = { path = "../../syntax", optional = true } [dependencies.godot] git = "https://github.com/godot-rust/gdext" diff --git a/docker/build.sh b/docker/build.sh index 99843ee..0dfc9dc 100644 --- a/docker/build.sh +++ b/docker/build.sh @@ -47,10 +47,16 @@ if [ "$CI_MODE" = true ]; then CI_FLAG="--ci" fi +# Enable editor-tooling feature for debug builds (provides validate() for Godot editor) +FEATURES="" +if [ "$BUILD_TYPE" != "release" ]; then + FEATURES="--features editor-tooling" +fi + case "$TARGET" in windows) cargo +nightly build --manifest-path bindings/godot/Cargo.toml \ - --target-dir target --target x86_64-pc-windows-gnu $CARGO_PROFILE_FLAG + --target-dir target --target x86_64-pc-windows-gnu $FEATURES $CARGO_PROFILE_FLAG cp target/x86_64-pc-windows-gnu/$PROFILE/bobbin_godot.dll "$BIN_DIR/bobbin_godot$SUFFIX.dll" ;; wasm) @@ -73,7 +79,7 @@ case "$TARGET" in cp "$WASM_FILE" "$BIN_DIR/bobbin_godot.wasm" ;; linux) - cargo +nightly build --manifest-path bindings/godot/Cargo.toml --target-dir target $CARGO_PROFILE_FLAG + cargo +nightly build --manifest-path bindings/godot/Cargo.toml --target-dir target $FEATURES $CARGO_PROFILE_FLAG cp target/$PROFILE/libbobbin_godot.so "$BIN_DIR/libbobbin_godot$SUFFIX.so" ;; all) diff --git a/runtime/Cargo.lock b/runtime/Cargo.lock index 869a624..80dffa5 100644 --- a/runtime/Cargo.lock +++ b/runtime/Cargo.lock @@ -15,6 +15,13 @@ dependencies = [ [[package]] name = "bobbin-runtime" version = "0.1.0" +dependencies = [ + "bobbin-syntax", +] + +[[package]] +name = "bobbin-syntax" +version = "0.1.0" dependencies = [ "ariadne", "strsim", diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 7e4e209..079fe79 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -4,5 +4,4 @@ version = "0.1.0" edition = "2024" [dependencies] -ariadne = "0.4" -strsim = "0.11" +bobbin-syntax = { path = "../syntax" } diff --git a/runtime/src/ast.rs b/runtime/src/ast.rs deleted file mode 100644 index f028a71..0000000 --- a/runtime/src/ast.rs +++ /dev/null @@ -1,68 +0,0 @@ -use crate::token::Span; - -/// Unique identifier for AST nodes that need semantic binding. -/// Used to track which variable reference resolves to which slot. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct NodeId(pub usize); - -#[derive(Debug, Clone)] -pub struct Script { - pub statements: Vec, -} - -#[derive(Debug, Clone)] -pub enum Stmt { - Line { parts: Vec, span: Span }, - TempDecl(VarBindingData), - SaveDecl(VarBindingData), - ExternDecl(ExternDeclData), - Assignment(VarBindingData), - ChoiceSet { choices: Vec }, -} - -#[derive(Debug, Clone)] -pub struct Choice { - pub parts: Vec, - pub span: Span, - /// Nested statements to execute when this choice is selected - pub nested: Vec, -} - -/// A part of text content - either literal text or a variable reference -#[derive(Debug, Clone)] -pub enum TextPart { - Literal { - text: String, - span: Span, - }, - VarRef { - id: NodeId, - name: String, - span: Span, - }, -} - -/// A literal value in declarations -#[derive(Debug, Clone)] -pub enum Literal { - String(String), - Number(f64), - Bool(bool), -} - -/// Shared data for variable binding operations (declarations and assignments) -#[derive(Debug, Clone)] -pub struct VarBindingData { - pub id: NodeId, - pub name: String, - pub value: Literal, - pub span: Span, -} - -/// Declaration of a host-provided variable (read-only from dialogue perspective) -#[derive(Debug, Clone)] -pub struct ExternDeclData { - pub id: NodeId, - pub name: String, - pub span: Span, -} diff --git a/runtime/src/compiler.rs b/runtime/src/compiler.rs index 4069ec5..635369c 100644 --- a/runtime/src/compiler.rs +++ b/runtime/src/compiler.rs @@ -1,6 +1,6 @@ -use crate::ast::{Literal, NodeId, Script, Stmt, TextPart, VarBindingData}; +use bobbin_syntax::{Literal, NodeId, Script, Stmt, SymbolTable, TextPart, VarBindingData}; + use crate::chunk::{Chunk, Instruction, Value}; -use crate::resolver::SymbolTable; #[derive(Debug, Clone)] pub enum CompileError {} diff --git a/runtime/src/diagnostic/convert.rs b/runtime/src/diagnostic/convert.rs deleted file mode 100644 index 4518846..0000000 --- a/runtime/src/diagnostic/convert.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! Conversion traits for turning errors into diagnostics. -//! -//! The `IntoDiagnostic` trait provides a uniform way to convert different -//! error types into `Diagnostic` values for rendering. - -use super::{Diagnostic, Matcher}; - -/// Context provided during diagnostic conversion. -/// -/// This carries information needed to produce enhanced diagnostics, -/// such as the list of known variables for fuzzy matching. -pub struct DiagnosticContext<'a> { - /// Known variable names for "did you mean?" suggestions. - pub known_variables: &'a [String], - /// The fuzzy matcher to use for suggestions. - pub matcher: &'a dyn Matcher, -} - -impl<'a> DiagnosticContext<'a> { - /// Create a new context with the given variables and matcher. - pub fn new(known_variables: &'a [String], matcher: &'a dyn Matcher) -> Self { - Self { - known_variables, - matcher, - } - } - - /// Find a similar variable name for "did you mean?" suggestions. - pub fn find_similar_variable(&self, name: &str) -> Option<&str> { - self.matcher - .best_match(name, self.known_variables) - .map(|(s, _)| s) - } -} - -/// Trait for converting an error into a diagnostic. -/// -/// Implemented by all error types in the pipeline (LexicalError, ParseError, -/// SemanticError, RuntimeError) to provide rich diagnostic output. -pub trait IntoDiagnostic { - /// Convert this error into a diagnostic. - /// - /// The context provides information for enhanced diagnostics like - /// "did you mean?" suggestions. - fn into_diagnostic(self, ctx: &DiagnosticContext) -> Diagnostic; -} diff --git a/runtime/src/diagnostic/fuzzy.rs b/runtime/src/diagnostic/fuzzy.rs deleted file mode 100644 index 829b816..0000000 --- a/runtime/src/diagnostic/fuzzy.rs +++ /dev/null @@ -1,134 +0,0 @@ -//! Fuzzy string matching adapter for "did you mean?" suggestions. -//! -//! The `Matcher` trait abstracts over string similarity algorithms, -//! allowing the matching implementation to be swapped without changing -//! the diagnostic logic. - -use strsim::jaro_winkler; - -/// Trait for fuzzy string matching. -/// -/// Implementations find the best match for a query string among candidates, -/// used for "did you mean 'X'?" suggestions on undefined variables. -pub trait Matcher { - /// Find the best match for `query` among `candidates`. - /// - /// Returns the best matching candidate and its similarity score (0.0 to 1.0), - /// or `None` if no candidate meets the minimum threshold. - fn best_match<'a>(&self, query: &str, candidates: &'a [String]) -> Option<(&'a str, f64)>; - - /// Find all matches above the threshold, sorted by score descending. - fn find_similar<'a>(&self, query: &str, candidates: &'a [String]) -> Vec<(&'a str, f64)>; -} - -/// Jaro-Winkler based matcher using the strsim crate. -/// -/// Jaro-Winkler is well-suited for matching variable names because it: -/// - Favors matching prefixes (good for `player_name` vs `player_naem`) -/// - Handles transpositions well (catches common typos) -/// - Works well with descriptive names common in narrative scripts -#[derive(Debug, Clone)] -pub struct JaroWinklerMatcher { - /// Minimum similarity score (0.0 to 1.0) to consider a match. - /// Typical values: 0.7 for loose matching, 0.8 for stricter matching. - pub threshold: f64, -} - -impl JaroWinklerMatcher { - /// Create a new matcher with the given threshold. - pub fn new(threshold: f64) -> Self { - Self { threshold } - } - - /// Create a new matcher with a sensible default threshold (0.7). - pub fn default_threshold() -> Self { - Self::new(0.7) - } -} - -impl Default for JaroWinklerMatcher { - fn default() -> Self { - Self::default_threshold() - } -} - -impl Matcher for JaroWinklerMatcher { - fn best_match<'a>(&self, query: &str, candidates: &'a [String]) -> Option<(&'a str, f64)> { - candidates - .iter() - .map(|c| (c.as_str(), jaro_winkler(query, c))) - .filter(|(_, score)| *score >= self.threshold) - .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)) - } - - fn find_similar<'a>(&self, query: &str, candidates: &'a [String]) -> Vec<(&'a str, f64)> { - let mut matches: Vec<_> = candidates - .iter() - .map(|c| (c.as_str(), jaro_winkler(query, c))) - .filter(|(_, score)| *score >= self.threshold) - .collect(); - - matches.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); - matches - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn matches_typo() { - let matcher = JaroWinklerMatcher::default(); - let candidates = vec!["player_name".to_string(), "gold".to_string()]; - - let result = matcher.best_match("player_naem", &candidates); - assert!(result.is_some()); - let (matched, score) = result.unwrap(); - assert_eq!(matched, "player_name"); - assert!(score > 0.9); - } - - #[test] - fn no_match_for_unrelated() { - let matcher = JaroWinklerMatcher::default(); - let candidates = vec!["player_name".to_string()]; - - let result = matcher.best_match("completely_different", &candidates); - assert!(result.is_none()); - } - - #[test] - fn finds_multiple_similar() { - let matcher = JaroWinklerMatcher::new(0.6); - let candidates = vec![ - "player_name".to_string(), - "player_health".to_string(), - "enemy_name".to_string(), - ]; - - let results = matcher.find_similar("player", &candidates); - assert!(!results.is_empty()); - // Should find player_name and player_health - assert!(results.iter().any(|(s, _)| *s == "player_name")); - assert!(results.iter().any(|(s, _)| *s == "player_health")); - } - - #[test] - fn threshold_filters_results() { - let strict_matcher = JaroWinklerMatcher::new(0.95); - let candidates = vec!["name".to_string()]; - - // "naem" has a high score but might not hit 0.95 - // With jaro_winkler, "name" vs "naem" scores around 0.93, so this should be None - let strict_result = strict_matcher.best_match("naem", &candidates); - assert!( - strict_result.is_none(), - "strict matcher (0.95 threshold) should reject ~0.93 similarity" - ); - - let loose_matcher = JaroWinklerMatcher::new(0.8); - let result = loose_matcher.best_match("naem", &candidates); - assert!(result.is_some()); - } -} diff --git a/runtime/src/diagnostic/mod.rs b/runtime/src/diagnostic/mod.rs deleted file mode 100644 index 0b01be7..0000000 --- a/runtime/src/diagnostic/mod.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! Diagnostic system for rich error reporting. -//! -//! This module provides infrastructure for producing Rust/Elm-quality error messages -//! with source snippets, multi-span labels, and auto-fix suggestions. -//! -//! # Architecture -//! -//! The diagnostic system follows the adapter pattern for flexibility: -//! -//! - [`Diagnostic`] - Pure data type representing an error/warning -//! - [`Renderer`] - Trait for rendering diagnostics (terminal, LSP, etc.) -//! - [`Matcher`] - Trait for fuzzy string matching ("did you mean?") -//! -//! External dependencies (ariadne, strsim) are wrapped behind traits, -//! allowing them to be swapped out if needed. - -mod convert; -mod fuzzy; -mod render; -mod types; - -pub use convert::{DiagnosticContext, IntoDiagnostic}; -pub use fuzzy::{JaroWinklerMatcher, Matcher}; -pub use render::{AriadneRenderer, Renderer}; -pub use types::{Diagnostic, Label, LabelStyle, Severity, Suggestion}; diff --git a/runtime/src/diagnostic/render.rs b/runtime/src/diagnostic/render.rs deleted file mode 100644 index 967462a..0000000 --- a/runtime/src/diagnostic/render.rs +++ /dev/null @@ -1,188 +0,0 @@ -//! Renderer adapter for diagnostic output. -//! -//! The `Renderer` trait abstracts over different output formats (terminal, LSP, JSON). -//! This allows swapping rendering implementations without changing diagnostic logic. - -use ariadne::{Color, Config, IndexType, Label as AriadneLabel, Report, ReportKind, Source}; - -use super::{Diagnostic, LabelStyle, Severity}; - -/// Trait for rendering diagnostics to a string. -/// -/// This abstraction allows different rendering backends (terminal with colors, -/// plain text, LSP JSON) without modifying the core diagnostic types. -pub trait Renderer { - /// Render a diagnostic to a string. - /// - /// # Arguments - /// * `diagnostic` - The diagnostic to render - /// * `source_id` - A name for the source (e.g., filename) - /// * `source` - The source code text - fn render(&self, diagnostic: &Diagnostic, source_id: &str, source: &str) -> String; - - /// Render multiple diagnostics to a string. - fn render_all(&self, diagnostics: &[Diagnostic], source_id: &str, source: &str) -> String { - diagnostics - .iter() - .map(|d| self.render(d, source_id, source)) - .collect::>() - .join("\n") - } -} - -/// Ariadne-based renderer for beautiful terminal output. -/// -/// Produces colorized output with source snippets and underlines, -/// similar to Rust compiler errors. -#[derive(Debug, Default)] -pub struct AriadneRenderer { - /// Whether to use colors in output. - pub colors: bool, -} - -impl AriadneRenderer { - /// Create a new renderer with colors enabled. - pub fn new() -> Self { - Self { colors: true } - } - - /// Create a new renderer without colors. - pub fn without_colors() -> Self { - Self { colors: false } - } -} - -impl Renderer for AriadneRenderer { - fn render(&self, diagnostic: &Diagnostic, source_id: &str, source: &str) -> String { - let kind = match diagnostic.severity { - Severity::Error => ReportKind::Error, - Severity::Warning => ReportKind::Warning, - Severity::Note => ReportKind::Advice, - Severity::Help => ReportKind::Advice, - }; - - // Start building the report with the first label's span as the primary location - let offset = diagnostic.labels.first().map(|l| l.span.start).unwrap_or(0); - - let mut builder = Report::<(&str, std::ops::Range)>::build(kind, source_id, offset) - .with_config( - Config::default() - .with_color(self.colors) - .with_index_type(IndexType::Byte), - ) - .with_message(&diagnostic.message); - - // Add labels - for label in &diagnostic.labels { - let color = match label.style { - LabelStyle::Primary => Color::Red, - LabelStyle::Secondary => Color::Blue, - }; - - let ariadne_label = AriadneLabel::new((source_id, label.span.start..label.span.end)) - .with_message(&label.message) - .with_color(color); - - builder = builder.with_label(ariadne_label); - } - - // Add notes - for note in &diagnostic.notes { - builder = builder.with_note(note); - } - - // Add suggestions as help messages - for suggestion in &diagnostic.suggestions { - builder = builder.with_help(&suggestion.message); - } - - let report = builder.finish(); - - // Render to string - let mut output = Vec::new(); - report - .write((source_id, Source::from(source)), &mut output) - .expect("write to Vec should not fail"); - - String::from_utf8(output).expect("ariadne output should be valid UTF-8") - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::token::Span; - - #[test] - fn render_simple_error() { - let diagnostic = Diagnostic::error( - "undefined variable 'foo'", - Span { start: 7, end: 10 }, - "not defined", - ); - - let renderer = AriadneRenderer::without_colors(); - let output = renderer.render(&diagnostic, "test.bobbin", "Hello, foo!"); - - assert!(output.contains("undefined variable")); - assert!(output.contains("not defined")); - } - - #[test] - fn render_with_suggestion() { - let diagnostic = Diagnostic::error( - "undefined variable 'naem'", - Span { start: 7, end: 11 }, - "not defined", - ) - .with_suggestion("did you mean 'name'?", Span { start: 7, end: 11 }, "name"); - - let renderer = AriadneRenderer::without_colors(); - let output = renderer.render(&diagnostic, "test.bobbin", "Hello, naem!"); - - assert!(output.contains("did you mean")); - } - - #[test] - fn render_with_secondary_label() { - let diagnostic = Diagnostic::error( - "variable 'x' shadows previous declaration", - Span { start: 20, end: 21 }, - "shadows previous declaration", - ) - .with_secondary(Span { start: 5, end: 6 }, "previously declared here"); - - let renderer = AriadneRenderer::without_colors(); - let source = "temp x = 1\ntemp x = 2"; - let output = renderer.render(&diagnostic, "test.bobbin", source); - - assert!(output.contains("shadows")); - assert!(output.contains("previously declared")); - } - - #[test] - fn render_multiline() { - // Test that multiline source renders correctly - let source = "line1\nline2\nerror here"; - - // "here" starts at byte 18 - let span_start = source.find("here").unwrap(); - let span_end = span_start + 4; - - let diagnostic = Diagnostic::error( - "test error", - Span { - start: span_start, - end: span_end, - }, - "error at 'here'", - ); - - let renderer = AriadneRenderer::without_colors(); - let output = renderer.render(&diagnostic, "test.bobbin", source); - - assert!(output.contains("test error")); - assert!(output.contains("here")); - assert!(output.contains("error at 'here'")); - } -} diff --git a/runtime/src/diagnostic/types.rs b/runtime/src/diagnostic/types.rs deleted file mode 100644 index 5982c7a..0000000 --- a/runtime/src/diagnostic/types.rs +++ /dev/null @@ -1,136 +0,0 @@ -//! Core diagnostic types for error reporting. -//! -//! These are pure data types with no rendering logic - rendering is handled -//! by the `Renderer` trait implementations. - -use crate::token::Span; - -/// A diagnostic message with source locations and optional suggestions. -#[derive(Debug, Clone)] -pub struct Diagnostic { - /// The severity of this diagnostic. - pub severity: Severity, - /// The primary message describing the issue. - pub message: String, - /// Labeled spans in the source code. - pub labels: Vec