diff --git a/Cargo.lock b/Cargo.lock index ce998dc..e7b36c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -157,6 +168,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.9.4" @@ -361,6 +378,19 @@ dependencies = [ "typenum", ] +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.9.0" @@ -476,6 +506,20 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -498,6 +542,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -516,8 +571,10 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -600,6 +657,12 @@ dependencies = [ "crunchy", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.16.0" @@ -940,7 +1003,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.0", ] [[package]] @@ -964,7 +1027,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ - "bitflags", + "bitflags 2.9.4", "cfg-if", "libc", ] @@ -1035,6 +1098,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.176" @@ -1083,6 +1152,28 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "lsp-types" +version = "0.94.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66bfd44a06ae10647fe3f8214762e9369fd4248df1350924b4ef9e770a85ea1" +dependencies = [ + "bitflags 1.3.2", + "serde", + "serde_json", + "serde_repr", + "url", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.7.6" @@ -1205,10 +1296,6 @@ dependencies = [ "mlf-lang", ] -[[package]] -name = "mlf-codegen-java" -version = "0.1.0" - [[package]] name = "mlf-codegen-rust" version = "0.1.0" @@ -1241,6 +1328,20 @@ dependencies = [ "nom 8.0.0", ] +[[package]] +name = "mlf-lsp" +version = "0.1.0" +dependencies = [ + "mlf-diagnostics", + "mlf-lang", + "serde", + "serde_json", + "tokio", + "tower-lsp", + "tracing", + "tracing-subscriber", +] + [[package]] name = "mlf-playground-wasm" version = "0.1.0" @@ -1317,6 +1418,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.1", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -1359,7 +1469,7 @@ version = "0.10.73" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ - "bitflags", + "bitflags 2.9.4", "cfg-if", "foreign-types", "libc", @@ -1432,6 +1542,26 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1564,7 +1694,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.9.4", ] [[package]] @@ -1629,7 +1759,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", - "tower", + "tower 0.5.2", "tower-http", "tower-service", "url", @@ -1670,7 +1800,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags", + "bitflags 2.9.4", "errno", "libc", "linux-raw-sys", @@ -1752,7 +1882,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.9.4", "core-foundation", "core-foundation-sys", "libc", @@ -1824,6 +1954,17 @@ dependencies = [ "serde_core", ] +[[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.106", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -1856,12 +1997,30 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + [[package]] name = "slab" version = "0.4.11" @@ -2000,7 +2159,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags", + "bitflags 2.9.4", "core-foundation", "system-configuration-sys", ] @@ -2088,6 +2247,15 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.44" @@ -2154,12 +2322,26 @@ dependencies = [ "io-uring", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "slab", "socket2 0.6.0", + "tokio-macros", "windows-sys 0.59.0", ] +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "tokio-native-tls" version = "0.3.1" @@ -2234,6 +2416,20 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower" version = "0.5.2" @@ -2255,14 +2451,14 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags", + "bitflags 2.9.4", "bytes", "futures-util", "http", "http-body", "iri-string", "pin-project-lite", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", ] @@ -2273,6 +2469,40 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" +[[package]] +name = "tower-lsp" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ba052b54a6627628d9b3c34c176e7eda8359b7da9acd497b9f20998d118508" +dependencies = [ + "async-trait", + "auto_impl", + "bytes", + "dashmap", + "futures", + "httparse", + "lsp-types", + "memchr", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tower 0.4.13", + "tower-lsp-macros", + "tracing", +] + +[[package]] +name = "tower-lsp-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84fd902d4e0b9a4b27f2f440108dc034e1758628a9b702f8ec61ad66355422fa" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "tower-service" version = "0.3.3" @@ -2308,6 +2538,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -2400,6 +2660,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index 22846f5..3068d4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,14 @@ [workspace] resolver = "3" -members = ["codegen-plugins/mlf-codegen-go", "codegen-plugins/mlf-codegen-java", "codegen-plugins/mlf-codegen-rust","codegen-plugins/mlf-codegen-typescript", - "mlf-cli", "mlf-codegen", "mlf-diagnostics", +members = [ + "codegen-plugins/mlf-codegen-go", + "codegen-plugins/mlf-codegen-rust", + "codegen-plugins/mlf-codegen-typescript", + "mlf-cli", + "mlf-codegen", + "mlf-diagnostics", "mlf-lang", + "mlf-lsp", "mlf-validation", "mlf-wasm", "tree-sitter-mlf", "website/mlf-playground-wasm"] diff --git a/README.md b/README.md index 02d40de..5dd3562 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ def type replyRef = { Right now you can only install mlf from source: +### CLI Tool + ```bash # Install with all code generators (default: TypeScript, Go, Rust) cargo install --path mlf-cli --all-features @@ -37,6 +39,60 @@ cargo install --path mlf-cli --no-default-features --features typescript,go cargo install --path mlf-cli --no-default-features ``` +### Language Server + +For editor integration with real-time diagnostics: + +```bash +# Build the language server +cargo build --release -p mlf-lsp + +# The binary will be at: target/release/mlf-lsp +``` + +#### Editor Setup + +**VS Code**: Create an extension configuration: +```json +{ + "languageServer": { + "module": "/path/to/mlf-lsp", + "args": [], + "filetypes": ["mlf"] + } +} +``` + +**Neovim**: Add to your LSP config: +```lua +local lspconfig = require('lspconfig') +local configs = require('lspconfig.configs') + +configs.mlf = { + default_config = { + cmd = { '/path/to/mlf-lsp' }, + filetypes = { 'mlf' }, + root_dir = lspconfig.util.root_pattern('mlf.toml', '.git'), + }, +} + +lspconfig.mlf.setup{} +``` + +**Helix**: Add to `languages.toml`: +```toml +[[language]] +name = "mlf" +scope = "source.mlf" +file-types = ["mlf"] +language-servers = ["mlf-lsp"] + +[language-server.mlf-lsp] +command = "/path/to/mlf-lsp" +``` + +See [mlf-lsp/README.md](mlf-lsp/README.md) for more details. + ## Documentation Visit the [MLF website](https://mlf.lol/docs) for comprehensive documentation, guides, and examples. diff --git a/codegen-plugins/mlf-codegen-java/Cargo.toml b/codegen-plugins/mlf-codegen-java/Cargo.toml deleted file mode 100644 index e0f7503..0000000 --- a/codegen-plugins/mlf-codegen-java/Cargo.toml +++ /dev/null @@ -1,6 +0,0 @@ -[package] -name = "mlf-codegen-java" -version = "0.1.0" -edition = "2024" - -[dependencies] diff --git a/codegen-plugins/mlf-codegen-java/src/lib.rs b/codegen-plugins/mlf-codegen-java/src/lib.rs deleted file mode 100644 index b93cf3f..0000000 --- a/codegen-plugins/mlf-codegen-java/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} diff --git a/mlf-diagnostics/src/lib.rs b/mlf-diagnostics/src/lib.rs index 0d03267..d22d6b6 100644 --- a/mlf-diagnostics/src/lib.rs +++ b/mlf-diagnostics/src/lib.rs @@ -216,6 +216,12 @@ fn format_validation_error(f: &mut fmt::Formatter<'_>, error: &ValidationError) name, namespace_suffix ) } + ValidationError::CircularImport { cycle, .. } => { + write!(f, "Circular import detected: {}", cycle.join(" → ")) + } + ValidationError::UnusedImport { name, .. } => { + write!(f, "Unused import '{}'", name) + } } } @@ -230,6 +236,8 @@ fn get_error_code(error: &ValidationError) -> &'static str { ValidationError::AmbiguousMain { .. } => "mlf::ambiguous_main", ValidationError::MultipleMain { .. } => "mlf::multiple_main", ValidationError::ConflictNotAllowed { .. } => "mlf::conflict_not_allowed", + ValidationError::CircularImport { .. } => "mlf::circular_import", + ValidationError::UnusedImport { .. } => "mlf::unused_import", } } @@ -301,6 +309,14 @@ fn get_error_labels(error: &ValidationError) -> Vec { span.start..span.end, format!("'{}' conflicts with another definition", name), )], + ValidationError::CircularImport { span, cycle, .. } => vec![LabeledSpan::at( + span.start..span.end, + format!("Import creates a cycle: {}", cycle.join(" → ")), + )], + ValidationError::UnusedImport { span, name, .. } => vec![LabeledSpan::at( + span.start..span.end, + format!("Import '{}' is never used", name), + )], } } @@ -331,6 +347,27 @@ fn get_error_help(error: &ValidationError) -> Option> { "Numeric constraints (minimum, maximum) can only be applied to integer or number types.", )) } + ValidationError::InvalidConstraint { message, .. } + if message.contains("Blob constraint on non-blob") => + { + Some(Box::new( + "Blob constraints (accept, maxSize) can only be applied to blob types.", + )) + } + ValidationError::InvalidConstraint { message, .. } + if message.contains("Length constraint") => + { + Some(Box::new( + "Length constraints (minLength, maxLength) can be applied to strings or arrays.", + )) + } + ValidationError::InvalidConstraint { message, .. } + if message.contains("Union type must have at least one member") => + { + Some(Box::new( + "Union types must contain at least one type. Add a type to the union or use a different type.", + )) + } ValidationError::ConstraintTooPermissive { message, .. } if message.contains("maxLength") => { Some(Box::new( "When refining a constrained type, maxLength can only decrease (become more restrictive).", @@ -341,6 +378,77 @@ fn get_error_help(error: &ValidationError) -> Option> { "When refining a constrained type, minLength can only increase (become more restrictive).", )) } + ValidationError::ConstraintTooPermissive { message, .. } if message.contains("maximum") => { + Some(Box::new( + "When refining a constrained type, maximum can only decrease (become more restrictive).", + )) + } + ValidationError::ConstraintTooPermissive { message, .. } if message.contains("minimum") => { + Some(Box::new( + "When refining a constrained type, minimum can only increase (become more restrictive).", + )) + } + ValidationError::ConstraintTooPermissive { message, .. } if message.contains("maxGraphemes") => { + Some(Box::new( + "When refining a constrained type, maxGraphemes can only decrease (become more restrictive).", + )) + } + ValidationError::ConstraintTooPermissive { message, .. } if message.contains("minGraphemes") => { + Some(Box::new( + "When refining a constrained type, minGraphemes can only increase (become more restrictive).", + )) + } + ValidationError::ConstraintTooPermissive { message, .. } if message.contains("maxSize") => { + Some(Box::new( + "When refining a constrained type, maxSize can only decrease (become more restrictive).", + )) + } + ValidationError::ConstraintTooPermissive { message, .. } if message.contains("enum") => { + Some(Box::new( + "When refining a constrained type, enum values must be a subset of the base enum.", + )) + } + ValidationError::DuplicateDefinition { .. } => { + Some(Box::new( + "Each name can only be defined once in a module. Consider renaming one of the items or using @main annotation if they match the namespace suffix.", + )) + } + ValidationError::ReservedName { name, .. } if name == "main" => { + Some(Box::new( + "The name 'main' is reserved and cannot be used as an item name. Use @main annotation on an item instead.", + )) + } + ValidationError::ReservedName { name, .. } if name == "defs" => { + Some(Box::new( + "The name 'defs' is reserved for future use and cannot be used as an item name.", + )) + } + ValidationError::AmbiguousMain { .. } => { + Some(Box::new( + "When multiple items have the same name as the namespace suffix, use @main to mark which one is the primary definition.", + )) + } + ValidationError::MultipleMain { .. } => { + Some(Box::new( + "Only one item can be marked with @main. Remove the @main annotation from all but one item.", + )) + } + ValidationError::ConflictNotAllowed { namespace_suffix, .. } => { + Some(Box::new(format!( + "Name conflicts are only allowed when the item name matches the namespace suffix ('{}').", + namespace_suffix + ))) + } + ValidationError::CircularImport { .. } => { + Some(Box::new( + "Circular imports are not allowed. Reorganize your modules to break the cycle.", + )) + } + ValidationError::UnusedImport { .. } => { + Some(Box::new( + "This import is never used. Consider removing it to keep the code clean.", + )) + } _ => None, } } @@ -356,6 +464,8 @@ pub fn get_error_module_namespace_str(error: &ValidationError) -> &str { ValidationError::AmbiguousMain { module_namespace, .. } => module_namespace, ValidationError::MultipleMain { module_namespace, .. } => module_namespace, ValidationError::ConflictNotAllowed { module_namespace, .. } => module_namespace, + ValidationError::CircularImport { module_namespace, .. } => module_namespace, + ValidationError::UnusedImport { module_namespace, .. } => module_namespace, } } diff --git a/mlf-lang/src/error.rs b/mlf-lang/src/error.rs index 21ce115..69885a9 100644 --- a/mlf-lang/src/error.rs +++ b/mlf-lang/src/error.rs @@ -21,6 +21,8 @@ pub enum ValidationError { AmbiguousMain { name: String, namespace_suffix: String, first_span: Span, second_span: Span, module_namespace: String }, MultipleMain { name: String, first_span: Span, second_span: Span, module_namespace: String }, ConflictNotAllowed { name: String, namespace_suffix: String, span: Span, module_namespace: String }, + CircularImport { cycle: Vec, span: Span, module_namespace: String }, + UnusedImport { name: String, span: Span, module_namespace: String }, } #[derive(Debug, Clone, Default)] diff --git a/mlf-lang/src/workspace.rs b/mlf-lang/src/workspace.rs index 08d1b2a..b7aaa63 100644 --- a/mlf-lang/src/workspace.rs +++ b/mlf-lang/src/workspace.rs @@ -895,10 +895,6 @@ impl Workspace { annotations.iter().any(|ann| ann.name.name == "main") } - fn is_main_eligible_item(item: &Item) -> bool { - matches!(item, Item::Record(_) | Item::Query(_) | Item::Procedure(_) | Item::Subscription(_) | Item::DefType(_)) - } - fn build_symbol_table(namespace: &str, lexicon: &Lexicon) -> Result { let mut symbols = SymbolTable { types: BTreeMap::new(), @@ -1424,16 +1420,6 @@ impl Workspace { } } -impl Symbol { - fn span(&self) -> Span { - match self { - Symbol::Record { span, .. } => *span, - Symbol::Alias { span, .. } => *span, - Symbol::Token { span, .. } => *span, - } - } -} - impl Default for Workspace { fn default() -> Self { Self::new() diff --git a/mlf-lsp/Cargo.toml b/mlf-lsp/Cargo.toml new file mode 100644 index 0000000..da9df64 --- /dev/null +++ b/mlf-lsp/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "mlf-lsp" +version = "0.1.0" +edition = "2024" +description = "Language Server Protocol implementation for MLF" + +[dependencies] +mlf-lang = { path = "../mlf-lang" } +mlf-diagnostics = { path = "../mlf-diagnostics" } + +# LSP +tower-lsp = "0.20" +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[lib] +name = "mlf_lsp" +path = "src/lib.rs" + +[[bin]] +name = "mlf-lsp" +path = "src/main.rs" diff --git a/mlf-lsp/README.md b/mlf-lsp/README.md new file mode 100644 index 0000000..31178cf --- /dev/null +++ b/mlf-lsp/README.md @@ -0,0 +1,145 @@ +# mlf-lsp + +Language Server Protocol (LSP) implementation for Matt's Lexicon Format (MLF). + +## Features + +### Currently Implemented + +- **Diagnostics**: Real-time syntax error reporting as you type +- **Document Synchronization**: Tracks open/changed/closed MLF files +- **Hover Information**: Shows type information, documentation comments, and field details on hover +- **Completion**: Auto-complete for keywords, primitive types, defined types, and constraint names +- **Go to Definition**: Jump to type definitions across files using workspace resolution +- **Workspace Support**: Automatically builds a workspace from open files with cross-file type resolution +- **Logging**: Server activity logging for debugging + +### Planned Features + +- **Formatting**: Auto-format MLF code from AST +- **Rename**: Rename symbols across files +- **Find References**: Find all usages of a symbol +- **Code Actions**: Quick fixes and refactoring +- **Signature Help**: Parameter hints for queries/procedures +- **Semantic Tokens**: Enhanced syntax highlighting +- **Document Symbols**: Outline view of definitions +- **Workspace Validation**: Multi-file validation with workspace support + +## Usage + +### Running the Server + +```bash +cargo build --release -p mlf-lsp +./target/release/mlf-lsp +``` + +The server communicates over stdin/stdout using the LSP protocol. + +### Editor Integration + +#### VS Code + +Create a VS Code extension that launches the LSP server: + +```json +{ + "languageServer": { + "module": "/path/to/mlf-lsp", + "args": [], + "filetypes": ["mlf"] + } +} +``` + +#### Neovim + +Configure with `nvim-lspconfig`: + +```lua +local lspconfig = require('lspconfig') +local configs = require('lspconfig.configs') + +configs.mlf = { + default_config = { + cmd = { '/path/to/mlf-lsp' }, + filetypes = { 'mlf' }, + root_dir = lspconfig.util.root_pattern('mlf.toml', '.git'), + }, +} + +lspconfig.mlf.setup{} +``` + +#### Helix + +Add to your `languages.toml`: + +```toml +[[language]] +name = "mlf" +scope = "source.mlf" +file-types = ["mlf"] +language-servers = ["mlf-lsp"] + +[language-server.mlf-lsp] +command = "/path/to/mlf-lsp" +``` + +## Development + +The LSP server is built using: +- **tower-lsp**: High-level LSP framework +- **tokio**: Async runtime +- **mlf-lang**: MLF parser and AST +- **mlf-diagnostics**: Error reporting + +### Architecture + +``` +mlf-lsp/ +├── src/ +│ ├── lib.rs # Library exports +│ ├── main.rs # Binary entry point +│ └── server.rs # LSP server implementation +└── Cargo.toml +``` + +### Adding Features + +1. **Hover**: Implement position-based type lookup in the AST +2. **Completion**: Build a context-aware symbol table from the workspace +3. **Go to Definition**: Track symbol definitions and references +4. **Formatting**: Generate MLF source from the AST with consistent formatting + +## Testing + +```bash +# Run tests +cargo test -p mlf-lsp + +# Test with LSP inspector +npm install -g @vscode/language-server-inspector +lsp-inspector --command "/path/to/mlf-lsp" +``` + +## Logging + +Set the `RUST_LOG` environment variable to control logging: + +```bash +RUST_LOG=debug mlf-lsp +RUST_LOG=mlf_lsp=trace mlf-lsp +``` + +Logs are written to stderr. + +## Contributing + +The LSP server is in early development. Contributions are welcome! + +Priority features: +1. Hover information with type details +2. Completion for types and keywords +3. Go to definition for imports and references +4. Formatting using mlf-lang AST diff --git a/mlf-lsp/src/lib.rs b/mlf-lsp/src/lib.rs new file mode 100644 index 0000000..9614b58 --- /dev/null +++ b/mlf-lsp/src/lib.rs @@ -0,0 +1,4 @@ +pub mod server; +pub mod utils; + +pub use server::MlfLanguageServer; diff --git a/mlf-lsp/src/main.rs b/mlf-lsp/src/main.rs new file mode 100644 index 0000000..ad8562c --- /dev/null +++ b/mlf-lsp/src/main.rs @@ -0,0 +1,21 @@ +use mlf_lsp::MlfLanguageServer; +use tower_lsp::{LspService, Server}; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() { + // Initialize logging + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .with_writer(std::io::stderr) + .init(); + + tracing::info!("Starting MLF Language Server"); + + let stdin = tokio::io::stdin(); + let stdout = tokio::io::stdout(); + + let (service, socket) = LspService::new(|client| MlfLanguageServer::new(client)); + + Server::new(stdin, stdout, socket).serve(service).await; +} diff --git a/mlf-lsp/src/server.rs b/mlf-lsp/src/server.rs new file mode 100644 index 0000000..cc4b830 --- /dev/null +++ b/mlf-lsp/src/server.rs @@ -0,0 +1,711 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use mlf_lang::ast::*; +use mlf_lang::Workspace; +use tower_lsp::jsonrpc::Result; +use tower_lsp::lsp_types::*; +use tower_lsp::{Client, LanguageServer}; + +use crate::utils::*; + +pub struct MlfLanguageServer { + client: Client, + documents: tokio::sync::RwLock>, + workspace: tokio::sync::RwLock>, +} + +struct DocumentState { + text: String, + lexicon: Option, + namespace: Option, +} + +impl MlfLanguageServer { + pub fn new(client: Client) -> Self { + Self { + client, + documents: tokio::sync::RwLock::new(HashMap::new()), + workspace: tokio::sync::RwLock::new(None), + } + } + + async fn parse_document(&self, uri: &Url, text: &str) { + // Parse the document + match mlf_lang::parser::parse_lexicon(text) { + Ok(lexicon) => { + // Extract namespace from file path + let namespace = extract_namespace_from_uri(uri); + + // Store parsed state + self.documents.write().await.insert( + uri.clone(), + DocumentState { + text: text.to_string(), + lexicon: Some(lexicon.clone()), + namespace: namespace.clone(), + }, + ); + + // Update workspace + self.update_workspace(uri, lexicon, namespace).await; + + // Clear diagnostics on success + self.client + .publish_diagnostics(uri.clone(), vec![], None) + .await; + } + Err(err) => { + // Store failed state + self.documents.write().await.insert( + uri.clone(), + DocumentState { + text: text.to_string(), + lexicon: None, + namespace: None, + }, + ); + + // Convert parse error to LSP diagnostic + let diagnostic = Diagnostic { + range: Range { + start: Position { + line: 0, + character: 0, + }, + end: Position { + line: 0, + character: 0, + }, + }, + severity: Some(DiagnosticSeverity::ERROR), + code: None, + code_description: None, + source: Some("mlf".to_string()), + message: format!("{:?}", err), + related_information: None, + tags: None, + data: None, + }; + + self.client + .publish_diagnostics(uri.clone(), vec![diagnostic], None) + .await; + } + } + } + + async fn update_workspace(&self, _uri: &Url, lexicon: Lexicon, namespace: Option) { + if let Some(ns) = namespace { + let mut workspace_guard = self.workspace.write().await; + + // Initialize workspace if needed + if workspace_guard.is_none() { + match Workspace::with_std() { + Ok(ws) => *workspace_guard = Some(ws), + Err(_) => { + *workspace_guard = Some(Workspace::new()); + } + } + } + + // Add or update module in workspace + if let Some(workspace) = workspace_guard.as_mut() { + let _ = workspace.add_module(ns.clone(), lexicon); + let _ = workspace.resolve(); + } + } + } + + async fn find_definition_in_workspace( + &self, + target_name: &str, + current_namespace: &str, + path: &Path, + ) -> Option<(Url, mlf_lang::span::Span)> { + let workspace_guard = self.workspace.read().await; + let workspace = workspace_guard.as_ref()?; + + // Resolve the reference to find which namespace it's in + let target_namespace = workspace.resolve_reference_namespace(path, current_namespace)?; + + // Find the document with that namespace + let documents = self.documents.read().await; + for (doc_uri, doc_state) in documents.iter() { + if let Some(doc_ns) = &doc_state.namespace { + if doc_ns == &target_namespace { + if let Some(lexicon) = &doc_state.lexicon { + // Find the item in this lexicon + for item in &lexicon.items { + if get_item_name(item) == target_name { + let def_span = match item { + Item::Record(r) => r.name.span, + Item::InlineType(i) => i.name.span, + Item::DefType(d) => d.name.span, + Item::Token(t) => t.name.span, + Item::Query(q) => q.name.span, + Item::Procedure(p) => p.name.span, + Item::Subscription(s) => s.name.span, + Item::Use(_) => continue, + }; + + return Some((doc_uri.clone(), def_span)); + } + } + } + } + } + } + + None + } +} + +/// Extract namespace from file URI +/// Tries to find mlf.toml to determine project root, otherwise uses filename +fn extract_namespace_from_uri(uri: &Url) -> Option { + let path = PathBuf::from(uri.path()); + + // Get the file stem (name without extension) + let file_stem = path.file_stem()?.to_str()?; + + // Try to find project root by looking for mlf.toml + let mut current = path.parent()?; + let mut components = vec![file_stem.to_string()]; + + loop { + // Check if mlf.toml exists here + if current.join("mlf.toml").exists() { + // Found project root, components already collected + break; + } + + // Add current directory name to components + if let Some(dir_name) = current.file_name() { + if let Some(name_str) = dir_name.to_str() { + // Skip common directories that shouldn't be part of namespace + if name_str != "src" && name_str != "lexicons" && name_str != "schemas" { + components.insert(0, name_str.to_string()); + } + } + } + + // Move up one directory + current = current.parent()?; + + // Stop at root or after reasonable depth + if current.parent().is_none() || components.len() > 10 { + break; + } + } + + Some(components.join(".")) +} + +#[tower_lsp::async_trait] +impl LanguageServer for MlfLanguageServer { + async fn initialize(&self, _: InitializeParams) -> Result { + Ok(InitializeResult { + capabilities: ServerCapabilities { + text_document_sync: Some(TextDocumentSyncCapability::Kind( + TextDocumentSyncKind::FULL, + )), + hover_provider: Some(HoverProviderCapability::Simple(true)), + completion_provider: Some(CompletionOptions { + resolve_provider: Some(false), + trigger_characters: Some(vec![".".to_string(), ":".to_string()]), + ..Default::default() + }), + definition_provider: Some(OneOf::Left(true)), + document_formatting_provider: Some(OneOf::Left(true)), + document_symbol_provider: Some(OneOf::Left(true)), + ..Default::default() + }, + server_info: Some(ServerInfo { + name: "mlf-lsp".to_string(), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + }), + ..Default::default() + }) + } + + async fn initialized(&self, _: InitializedParams) { + self.client + .log_message(MessageType::INFO, "MLF Language Server initialized") + .await; + } + + async fn shutdown(&self) -> Result<()> { + Ok(()) + } + + async fn did_open(&self, params: DidOpenTextDocumentParams) { + let uri = params.text_document.uri; + let text = params.text_document.text; + + self.client + .log_message(MessageType::INFO, format!("Opening document: {}", uri)) + .await; + + // Parse and send diagnostics + self.parse_document(&uri, &text).await; + } + + async fn did_change(&self, params: DidChangeTextDocumentParams) { + let uri = params.text_document.uri; + + if let Some(change) = params.content_changes.into_iter().next() { + let text = change.text; + + // Parse and send diagnostics + self.parse_document(&uri, &text).await; + } + } + + async fn did_close(&self, params: DidCloseTextDocumentParams) { + // Remove document from storage + self.documents.write().await.remove(¶ms.text_document.uri); + + // TODO: Could rebuild workspace without this module + } + + async fn hover(&self, params: HoverParams) -> Result> { + let uri = params.text_document_position_params.text_document.uri; + let position = params.text_document_position_params.position; + + let documents = self.documents.read().await; + if let Some(doc_state) = documents.get(&uri) { + if let Some(lexicon) = &doc_state.lexicon { + // Convert position to offset + if let Some(offset) = position_to_offset(&doc_state.text, position) { + // Find the item at this position + if let Some(item) = find_item_at_offset(lexicon, offset) { + let name = get_item_name(item); + let docs = get_item_docs(item); + + let mut contents = vec![]; + + // Add item kind and name + let kind = match item { + Item::Record(_) => "record", + Item::InlineType(_) => "inline type", + Item::DefType(_) => "def type", + Item::Token(_) => "token", + Item::Query(_) => "query", + Item::Procedure(_) => "procedure", + Item::Subscription(_) => "subscription", + Item::Use(_) => "use", + }; + + contents.push(MarkedString::LanguageString(LanguageString { + language: "mlf".to_string(), + value: format!("{} {}", kind, name), + })); + + // Add documentation if available + if !docs.is_empty() { + contents.push(MarkedString::String(docs.join("\n"))); + } + + // Add type information for fields + match item { + Item::Record(r) => { + if let Some(field) = find_field_at_offset(&r.fields, offset) { + let field_type = format_type(&field.ty); + let opt = if field.optional { "optional" } else { "required" }; + contents.push(MarkedString::String( + format!("Field: {} ({})", field_type, opt) + )); + } + } + Item::InlineType(i) => { + contents.push(MarkedString::String( + format!("Type: {}", format_type(&i.ty)) + )); + } + Item::DefType(d) => { + contents.push(MarkedString::String( + format!("Type: {}", format_type(&d.ty)) + )); + } + Item::Query(q) => { + if let Some(field) = find_field_at_offset(&q.params, offset) { + let field_type = format_type(&field.ty); + let opt = if field.optional { "optional" } else { "required" }; + contents.push(MarkedString::String( + format!("Parameter: {} ({})", field_type, opt) + )); + } + } + Item::Procedure(p) => { + if let Some(field) = find_field_at_offset(&p.params, offset) { + let field_type = format_type(&field.ty); + let opt = if field.optional { "optional" } else { "required" }; + contents.push(MarkedString::String( + format!("Parameter: {} ({})", field_type, opt) + )); + } + } + _ => {} + } + + return Ok(Some(Hover { + contents: HoverContents::Array(contents), + range: None, + })); + } + + // Check if we're hovering over a type reference + for item in &lexicon.items { + let type_to_check = match item { + Item::Record(r) => { + if let Some(field) = find_field_at_offset(&r.fields, offset) { + Some(&field.ty) + } else { + None + } + } + Item::InlineType(i) => Some(&i.ty), + Item::DefType(d) => Some(&d.ty), + Item::Query(q) => { + find_field_at_offset(&q.params, offset).map(|f| &f.ty) + } + Item::Procedure(p) => { + find_field_at_offset(&p.params, offset).map(|f| &f.ty) + } + _ => None, + }; + + if let Some(ty) = type_to_check { + if let Some(type_ref) = find_type_at_offset(ty, offset) { + if let Type::Reference { path, .. } = type_ref { + return Ok(Some(Hover { + contents: HoverContents::Scalar( + MarkedString::LanguageString(LanguageString { + language: "mlf".to_string(), + value: format!("type {}", path.to_string()), + }) + ), + range: None, + })); + } + } + } + } + } + } + } + + Ok(None) + } + + async fn completion(&self, params: CompletionParams) -> Result> { + let uri = params.text_document_position.text_document.uri; + let position = params.text_document_position.position; + + let documents = self.documents.read().await; + if let Some(doc_state) = documents.get(&uri) { + let mut completions = vec![]; + + // Always provide keywords + let keywords = vec![ + ("record", CompletionItemKind::KEYWORD, "Define a record type"), + ("inline type", CompletionItemKind::KEYWORD, "Define an inline type"), + ("def type", CompletionItemKind::KEYWORD, "Define a def type"), + ("token", CompletionItemKind::KEYWORD, "Define a token"), + ("query", CompletionItemKind::KEYWORD, "Define a query"), + ("procedure", CompletionItemKind::KEYWORD, "Define a procedure"), + ("subscription", CompletionItemKind::KEYWORD, "Define a subscription"), + ("use", CompletionItemKind::KEYWORD, "Import types from another module"), + ("constrained", CompletionItemKind::KEYWORD, "Add constraints to a type"), + ]; + + for (label, kind, detail) in keywords { + completions.push(CompletionItem { + label: label.to_string(), + kind: Some(kind), + detail: Some(detail.to_string()), + ..Default::default() + }); + } + + // Primitive types + let primitives = vec![ + "null", "boolean", "integer", "string", "bytes", "blob", + ]; + + for prim in primitives { + completions.push(CompletionItem { + label: prim.to_string(), + kind: Some(CompletionItemKind::TYPE_PARAMETER), + detail: Some("Primitive type".to_string()), + ..Default::default() + }); + } + + // If we have a parsed lexicon, provide type completions from current file + if let Some(lexicon) = &doc_state.lexicon { + for item in &lexicon.items { + let (name, kind, detail) = match item { + Item::Record(r) => (r.name.name.as_str(), CompletionItemKind::CLASS, "record"), + Item::InlineType(i) => (i.name.name.as_str(), CompletionItemKind::TYPE_PARAMETER, "inline type"), + Item::DefType(d) => (d.name.name.as_str(), CompletionItemKind::TYPE_PARAMETER, "def type"), + Item::Token(t) => (t.name.name.as_str(), CompletionItemKind::ENUM, "token"), + Item::Query(q) => (q.name.name.as_str(), CompletionItemKind::FUNCTION, "query"), + Item::Procedure(p) => (p.name.name.as_str(), CompletionItemKind::FUNCTION, "procedure"), + Item::Subscription(s) => (s.name.name.as_str(), CompletionItemKind::EVENT, "subscription"), + Item::Use(_) => continue, + }; + + completions.push(CompletionItem { + label: name.to_string(), + kind: Some(kind), + detail: Some(detail.to_string()), + ..Default::default() + }); + } + } + + // Check if we're at a field position to provide constraint completions + if let Some(offset) = position_to_offset(&doc_state.text, position) { + // Check if "constrained {" appears before cursor + if doc_state.text[..offset].ends_with("constrained {") + || doc_state.text[..offset].contains("constrained {") { + let constraint_items = vec![ + ("maxLength", "Maximum string/array length"), + ("minLength", "Minimum string/array length"), + ("maxGraphemes", "Maximum grapheme count"), + ("minGraphemes", "Minimum grapheme count"), + ("format", "String format (e.g., 'datetime', 'uri')"), + ("enum", "Enumerated values"), + ("knownValues", "Known values"), + ("minimum", "Minimum numeric value"), + ("maximum", "Maximum numeric value"), + ("accept", "Accepted MIME types for blobs"), + ("maxSize", "Maximum blob size in bytes"), + ("default", "Default value"), + ("const", "Constant value"), + ]; + + for (label, detail) in constraint_items { + completions.push(CompletionItem { + label: label.to_string(), + kind: Some(CompletionItemKind::PROPERTY), + detail: Some(detail.to_string()), + insert_text: Some(format!("{}: ", label)), + ..Default::default() + }); + } + } + } + + return Ok(Some(CompletionResponse::Array(completions))); + } + + Ok(None) + } + + async fn goto_definition( + &self, + params: GotoDefinitionParams, + ) -> Result> { + let uri = params.text_document_position_params.text_document.uri; + let position = params.text_document_position_params.position; + + self.client + .log_message( + MessageType::INFO, + format!("Go to definition request at {}:{}:{}", uri, position.line, position.character), + ) + .await; + + // Extract needed data from document state + let (text, lexicon, current_namespace) = { + let documents = self.documents.read().await; + if let Some(doc_state) = documents.get(&uri) { + ( + doc_state.text.clone(), + doc_state.lexicon.clone(), + doc_state.namespace.clone(), + ) + } else { + return Ok(None); + } + }; + + if let Some(lexicon) = lexicon { + if let Some(offset) = position_to_offset(&text, position) { + // Find type reference at this position + for item in &lexicon.items { + let type_to_check = match item { + Item::Record(r) => { + find_field_at_offset(&r.fields, offset).map(|f| &f.ty) + } + Item::InlineType(i) => Some(&i.ty), + Item::DefType(d) => Some(&d.ty), + Item::Query(q) => { + find_field_at_offset(&q.params, offset).map(|f| &f.ty) + } + Item::Procedure(p) => { + find_field_at_offset(&p.params, offset).map(|f| &f.ty) + } + _ => None, + }; + + if let Some(ty) = type_to_check { + if let Some(Type::Reference { path, .. }) = find_type_at_offset(ty, offset) { + // Find the definition of this type + let target_name = if path.segments.len() == 1 { + &path.segments[0].name + } else { + &path.segments.last().unwrap().name + }; + + // First try to find in current file + for target_item in &lexicon.items { + if get_item_name(target_item) == target_name { + let def_span = match target_item { + Item::Record(r) => r.name.span, + Item::InlineType(i) => i.name.span, + Item::DefType(d) => d.name.span, + Item::Token(t) => t.name.span, + Item::Query(q) => q.name.span, + Item::Procedure(p) => p.name.span, + Item::Subscription(s) => s.name.span, + Item::Use(_) => continue, + }; + + let range = span_to_range(&text, def_span); + + return Ok(Some(GotoDefinitionResponse::Scalar( + Location { + uri: uri.clone(), + range, + }, + ))); + } + } + + // Not found in current file - try workspace + if let Some(ref current_ns) = current_namespace { + if let Some((def_uri, def_span)) = + self.find_definition_in_workspace(target_name, current_ns, path).await { + + // Get text for span conversion + let documents = self.documents.read().await; + if let Some(target_doc) = documents.get(&def_uri) { + let range = span_to_range(&target_doc.text, def_span); + + return Ok(Some(GotoDefinitionResponse::Scalar( + Location { + uri: def_uri, + range, + }, + ))); + } + } + } + } + } + } + } + } + + Ok(None) + } + + async fn document_symbol( + &self, + params: DocumentSymbolParams, + ) -> Result> { + let uri = params.text_document.uri; + + let documents = self.documents.read().await; + if let Some(doc_state) = documents.get(&uri) { + if let Some(lexicon) = &doc_state.lexicon { + let mut symbols = vec![]; + + for item in &lexicon.items { + let (name, kind, range) = match item { + Item::Record(r) => ( + &r.name.name, + SymbolKind::STRUCT, + span_to_range(&doc_state.text, r.span), + ), + Item::InlineType(i) => ( + &i.name.name, + SymbolKind::TYPE_PARAMETER, + span_to_range(&doc_state.text, i.span), + ), + Item::DefType(d) => ( + &d.name.name, + SymbolKind::TYPE_PARAMETER, + span_to_range(&doc_state.text, d.span), + ), + Item::Token(t) => ( + &t.name.name, + SymbolKind::ENUM, + span_to_range(&doc_state.text, t.span), + ), + Item::Query(q) => ( + &q.name.name, + SymbolKind::FUNCTION, + span_to_range(&doc_state.text, q.span), + ), + Item::Procedure(p) => ( + &p.name.name, + SymbolKind::FUNCTION, + span_to_range(&doc_state.text, p.span), + ), + Item::Subscription(s) => ( + &s.name.name, + SymbolKind::EVENT, + span_to_range(&doc_state.text, s.span), + ), + Item::Use(_) => continue, + }; + + #[allow(deprecated)] + symbols.push(DocumentSymbol { + name: name.clone(), + detail: None, + kind, + tags: None, + deprecated: None, + range, + selection_range: range, + children: None, + }); + } + + return Ok(Some(DocumentSymbolResponse::Nested(symbols))); + } + } + + Ok(None) + } + + async fn formatting(&self, params: DocumentFormattingParams) -> Result>> { + let uri = params.text_document.uri; + + let text = { + let documents = self.documents.read().await; + documents.get(&uri).map(|d| d.text.clone()) + }; + + if let Some(text) = text { + // Parse and re-format + match mlf_lang::parser::parse_lexicon(&text) { + Ok(_lexicon) => { + // TODO: Generate formatted MLF from AST + return Ok(None); + } + Err(_) => { + // Don't format invalid documents + return Ok(None); + } + } + } + + Ok(None) + } +} diff --git a/mlf-lsp/src/utils.rs b/mlf-lsp/src/utils.rs new file mode 100644 index 0000000..6b6ac20 --- /dev/null +++ b/mlf-lsp/src/utils.rs @@ -0,0 +1,191 @@ +use mlf_lang::ast::*; +use mlf_lang::span::{Span, Spanned}; +use tower_lsp::lsp_types::Position; + +/// Convert LSP Position to byte offset in text +pub fn position_to_offset(text: &str, position: Position) -> Option { + let mut offset = 0; + let mut line = 0; + let mut character = 0; + + for ch in text.chars() { + if line == position.line as usize && character == position.character as usize { + return Some(offset); + } + + if ch == '\n' { + line += 1; + character = 0; + } else { + character += 1; + } + + offset += ch.len_utf8(); + } + + // Handle end of file + if line == position.line as usize && character == position.character as usize { + Some(offset) + } else { + None + } +} + +/// Convert byte offset to LSP Position +pub fn offset_to_position(text: &str, offset: usize) -> Position { + let mut line = 0; + let mut character = 0; + let mut current_offset = 0; + + for ch in text.chars() { + if current_offset >= offset { + break; + } + + if ch == '\n' { + line += 1; + character = 0; + } else { + character += 1; + } + + current_offset += ch.len_utf8(); + } + + Position { line: line as u32, character: character as u32 } +} + +/// Convert MLF Span to LSP Range +pub fn span_to_range(text: &str, span: Span) -> tower_lsp::lsp_types::Range { + let start = offset_to_position(text, span.start); + let end = offset_to_position(text, span.end); + tower_lsp::lsp_types::Range { start, end } +} + +/// Check if an offset is within a span +pub fn offset_in_span(offset: usize, span: Span) -> bool { + offset >= span.start && offset < span.end +} + +/// Find the item at a given offset +pub fn find_item_at_offset(lexicon: &Lexicon, offset: usize) -> Option<&Item> { + lexicon.items.iter().find(|item| { + let span = match item { + Item::Record(r) => r.span, + Item::InlineType(i) => i.span, + Item::DefType(d) => d.span, + Item::Token(t) => t.span, + Item::Query(q) => q.span, + Item::Procedure(p) => p.span, + Item::Subscription(s) => s.span, + Item::Use(u) => u.span, + }; + offset_in_span(offset, span) + }) +} + +/// Find the type reference at a given offset +pub fn find_type_at_offset(ty: &Type, offset: usize) -> Option<&Type> { + if !offset_in_span(offset, ty.span()) { + return None; + } + + match ty { + Type::Reference { .. } => Some(ty), + Type::Array { inner, .. } => find_type_at_offset(inner, offset), + Type::Union { types, .. } => { + for t in types { + if let Some(found) = find_type_at_offset(t, offset) { + return Some(found); + } + } + None + } + Type::Object { fields, .. } => { + for field in fields { + if offset_in_span(offset, field.span) { + if offset_in_span(offset, field.name.span) { + return None; // On field name, not type + } + return find_type_at_offset(&field.ty, offset); + } + } + None + } + Type::Parenthesized { inner, .. } => find_type_at_offset(inner, offset), + Type::Constrained { base, .. } => find_type_at_offset(base, offset), + _ => None, + } +} + +/// Find field at offset within a record/query/procedure +pub fn find_field_at_offset(fields: &[Field], offset: usize) -> Option<&Field> { + fields.iter().find(|field| offset_in_span(offset, field.span)) +} + +/// Get the item name +pub fn get_item_name(item: &Item) -> &str { + match item { + Item::Record(r) => &r.name.name, + Item::InlineType(i) => &i.name.name, + Item::DefType(d) => &d.name.name, + Item::Token(t) => &t.name.name, + Item::Query(q) => &q.name.name, + Item::Procedure(p) => &p.name.name, + Item::Subscription(s) => &s.name.name, + Item::Use(_) => "", + } +} + +/// Get documentation from an item +pub fn get_item_docs(item: &Item) -> Vec { + let docs = match item { + Item::Record(r) => &r.docs, + Item::InlineType(i) => &i.docs, + Item::DefType(d) => &d.docs, + Item::Token(t) => &t.docs, + Item::Query(q) => &q.docs, + Item::Procedure(p) => &p.docs, + Item::Subscription(s) => &s.docs, + Item::Use(_) => return vec![], + }; + + docs.iter().map(|doc| doc.text.clone()).collect() +} + +/// Format a type as a string for display +pub fn format_type(ty: &Type) -> String { + match ty { + Type::Primitive { kind, .. } => format!("{:?}", kind).to_lowercase(), + Type::Reference { path, .. } => path.to_string(), + Type::Array { inner, .. } => format!("{}[]", format_type(inner)), + Type::Union { types, closed, .. } => { + let type_str = types + .iter() + .map(|t| format_type(t)) + .collect::>() + .join(" | "); + if *closed { + format!("{} | !", type_str) + } else { + type_str + } + } + Type::Object { fields, .. } => { + let fields_str = fields + .iter() + .map(|f| { + let opt = if f.optional { "" } else { "!" }; + format!("{}{}: {}", f.name.name, opt, format_type(&f.ty)) + }) + .collect::>() + .join(", "); + format!("{{ {} }}", fields_str) + } + Type::Parenthesized { inner, .. } => format!("({})", format_type(inner)), + Type::Constrained { base, constraints, .. } => { + format!("{} constrained {{ {} constraints }}", format_type(base), constraints.len()) + } + Type::Unknown { .. } => "unknown".to_string(), + } +}