Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
416ea75
Add documents storage
tonythetender Nov 25, 2025
633f985
Integrate document store and notifications
tonythetender Nov 25, 2025
b70030d
Separate values assignment to different files
tonythetender Nov 26, 2025
245aa00
Add support for incremental change
tonythetender Nov 26, 2025
212dc70
Add notification for didOpen and didChange
tonythetender Nov 26, 2025
329a1b3
Fix enum conversion
tonythetender Nov 26, 2025
7fc5678
Add tests for document store
tonythetender Nov 26, 2025
cda721e
Ensure test wiring
tonythetender Nov 26, 2025
57f250e
Add more documentation to README.md
tonythetender Nov 26, 2025
5d93324
Merge branch 'roc-lang:main' into lsp-document-store
tonythetender Nov 28, 2025
be87ffa
Merge branch 'roc-lang:main' into lsp-syntax-parsing
tonythetender Nov 28, 2025
7c7fa6c
Add necessary modules import
tonythetender Nov 30, 2025
d6585ae
Add data structure for diagnostics
tonythetender Nov 30, 2025
21a412d
Add URI percent encoding and decoding
tonythetender Nov 30, 2025
cf7e299
Add more debugging flags
tonythetender Nov 30, 2025
b4773f6
Add optional file provider to BuildEnv and PackageEnv
tonythetender Dec 1, 2025
f7696de
Add diagnostics on onChange and onOpen
tonythetender Dec 1, 2025
f5a5427
Add more debugging flags
tonythetender Dec 1, 2025
d13d5f6
Add syntax diagnostic
tonythetender Dec 1, 2025
0bc2f0d
Wire up new tests and flags
tonythetender Dec 1, 2025
771372c
Add mention of new debug flags
tonythetender Dec 1, 2025
434ab2b
Add new imports, debug flags and diagnostics
tonythetender Dec 1, 2025
4bd319c
Add doc comments to pub struct
tonythetender Dec 1, 2025
f9aa0fc
Simplify projet root detection in setup
tonythetender Dec 1, 2025
a1047c3
Fix indentation
tonythetender Dec 1, 2025
50e98f3
Merge branch 'main' into lsp-syntax-parsing
tonythetender Dec 1, 2025
6bc3c82
Merge branch 'roc-lang:main' into lsp-syntax-parsing
tonythetender Dec 1, 2025
6bcaa30
Merge branch 'main' into lsp-syntax-parsing
lukewilliamboswell Dec 3, 2025
104e24d
Merge remote-tracking branch 'remote/main' into lsp-syntax-parsing
lukewilliamboswell Dec 3, 2025
fd2da1c
use a larger buffer for buffer
lukewilliamboswell Dec 3, 2025
44a02cb
fmt
lukewilliamboswell Dec 3, 2025
46413a1
zig lints
lukewilliamboswell Dec 3, 2025
35c48ca
typos
lukewilliamboswell Dec 3, 2025
02a70c9
windows fixes
lukewilliamboswell Dec 3, 2025
518e50d
Remove file that wasn't supposed to be there
tonythetender Dec 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/build/modules.zig
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ pub const ModuleType = enum {
.bundle => &.{ .base, .collections, .base58, .unbundle },
.unbundle => &.{ .base, .collections, .base58 },
.base58 => &.{},
.lsp => &.{},
.lsp => &.{ .compile, .reporting, .build_options, .fs },
};
}
};
Expand Down
22 changes: 21 additions & 1 deletion src/cli/cli_args.zig
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ pub const DocsArgs = struct {
/// Arguments for `roc experimental-lsp`
pub const ExperimentalLspArgs = struct {
debug_io: bool = false, // log the LSP messages to a temporary log file
debug_build: bool = false,
debug_syntax: bool = false,
debug_server: bool = false,
};

/// Parse a list of arguments.
Expand Down Expand Up @@ -640,6 +643,9 @@ fn parseDocs(args: []const []const u8) CliArgs {

fn parseExperimentalLsp(args: []const []const u8) CliArgs {
var debug_io = false;
var debug_build = false;
var debug_syntax = false;
var debug_server = false;

for (args) |arg| {
if (isHelpFlag(arg)) {
Expand All @@ -650,17 +656,31 @@ fn parseExperimentalLsp(args: []const []const u8) CliArgs {
\\
\\Options:
\\ --debug-transport Mirror all JSON-RPC traffic to a temp log file
\\ --debug-build Log build environment actions to the debug log
\\ --debug-syntax Log syntax/type checking steps to the debug log
\\ --debug-server Log server lifecycle details to the debug log
\\ -h, --help Print help
\\
};
} else if (mem.eql(u8, arg, "--debug-transport")) {
debug_io = true;
} else if (mem.eql(u8, arg, "--debug-build")) {
debug_build = true;
} else if (mem.eql(u8, arg, "--debug-syntax")) {
debug_syntax = true;
} else if (mem.eql(u8, arg, "--debug-server")) {
debug_server = true;
} else {
return CliArgs{ .problem = CliProblem{ .unexpected_argument = .{ .cmd = "experimental-lsp", .arg = arg } } };
}
}

return CliArgs{ .experimental_lsp = .{ .debug_io = debug_io } };
return CliArgs{ .experimental_lsp = .{
.debug_io = debug_io,
.debug_build = debug_build,
.debug_syntax = debug_syntax,
.debug_server = debug_server,
} };
}

fn parseRun(alloc: mem.Allocator, args: []const []const u8) std.mem.Allocator.Error!CliArgs {
Expand Down
7 changes: 6 additions & 1 deletion src/cli/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,12 @@ fn mainArgs(allocs: *Allocators, args: []const []const u8) !void {
.repl => rocRepl(allocs),
.version => stdout.print("Roc compiler version {s}\n", .{build_options.compiler_version}),
.docs => |docs_args| rocDocs(allocs, docs_args),
.experimental_lsp => |lsp_args| try lsp.runWithStdIo(allocs.gpa, lsp_args.debug_io),
.experimental_lsp => |lsp_args| try lsp.runWithStdIo(allocs.gpa, .{
.transport = lsp_args.debug_io,
.build = lsp_args.debug_build,
.syntax = lsp_args.debug_syntax,
.server = lsp_args.debug_server,
}),
.help => |help_message| {
try stdout.writeAll(help_message);
},
Expand Down
24 changes: 20 additions & 4 deletions src/compile/compile_build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const ModuleTimingInfo = @import("compile_package.zig").TimingInfo;
const ImportResolver = @import("compile_package.zig").ImportResolver;
const ScheduleHook = @import("compile_package.zig").ScheduleHook;
const CacheManager = @import("cache_manager.zig").CacheManager;
const FileProvider = @import("compile_package.zig").FileProvider;

// Threading features aren't available when targeting WebAssembly,
// so we disable them at comptime to prevent builds from failing.
Expand Down Expand Up @@ -246,7 +247,7 @@ const GlobalQueue = struct {
const module_state = sched.getModuleState(task.module_name).?;
if (module_state.phase == .Done and module_state.env != null) {
// Read the source file again to generate the cache key
const source = std.fs.cwd().readFileAlloc(be.gpa, module_state.path, 10 * 1024 * 1024) catch {
const source = be.readFile(module_state.path, 10 * 1024 * 1024) catch {
// If we can't read the file, skip caching
freeSlice(self.gpa, task.pkg);
freeSlice(self.gpa, task.module_name);
Expand Down Expand Up @@ -365,6 +366,8 @@ pub const BuildEnv = struct {

// Cache manager for compiled modules
cache_manager: ?*CacheManager = null,
// Optional virtual file provider
file_provider: ?FileProvider = null,

// Builtin modules (Bool, Try, Str) shared across all packages (heap-allocated to prevent moves)
builtin_modules: *BuiltinModules,
Expand Down Expand Up @@ -461,6 +464,11 @@ pub const BuildEnv = struct {
self.cache_manager = cache_manager;
}

/// Set a virtual file provider for this BuildEnv.
pub fn setFileProvider(self: *BuildEnv, provider: ?FileProvider) void {
self.file_provider = provider;
}

/// Build an app file specifically (validates it's an app)
pub fn buildApp(self: *BuildEnv, app_file: []const u8) !void {
// Build and let the main function handle everything
Expand Down Expand Up @@ -864,7 +872,7 @@ pub const BuildEnv = struct {
// Read source
const file_abs = try std.fs.path.resolve(self.gpa, &.{file_path});
defer self.gpa.free(file_abs);
const src = std.fs.cwd().readFileAlloc(self.gpa, file_abs, std.math.maxInt(usize)) catch |err| {
const src = self.readFile(file_abs, std.math.maxInt(usize)) catch |err| {
const report = blk: switch (err) {
error.FileNotFound => {
var report = Report.init(self.gpa, "FILE NOT FOUND", .fatal);
Expand Down Expand Up @@ -1149,6 +1157,15 @@ pub const BuildEnv = struct {
return try std.fs.path.resolve(self.gpa, &.{ cwd_tmp, path });
}

fn readFile(self: *BuildEnv, path: []const u8, max_bytes: usize) ![]u8 {
if (self.file_provider) |fp| {
if (try fp.read(fp.ctx, path, self.gpa)) |data| {
return data;
}
}
return std.fs.cwd().readFileAlloc(self.gpa, path, max_bytes);
}

/// Check if a path is a URL (http:// or https://)
fn isUrl(path: []const u8) bool {
return std.mem.startsWith(u8, path, "http://") or std.mem.startsWith(u8, path, "https://");
Expand Down Expand Up @@ -1373,6 +1390,7 @@ pub const BuildEnv = struct {
schedule_hook,
self.compiler_version,
self.builtin_modules,
self.file_provider,
);

const key = try self.gpa.dupe(u8, name);
Expand All @@ -1389,7 +1407,6 @@ pub const BuildEnv = struct {

const p_path = info.platform_path.?;

// Check if this is a URL - if so, download and resolve to cache path
const abs = if (isUrl(p_path))
try self.resolveUrlPackage(p_path)
else
Expand Down Expand Up @@ -1428,7 +1445,6 @@ pub const BuildEnv = struct {
const alias = e.key_ptr.*;
const path = e.value_ptr.*;

// Check if this is a URL - if so, download and resolve to cache path
const abs = if (isUrl(path))
try self.resolveUrlPackage(path)
else
Expand Down
24 changes: 22 additions & 2 deletions src/compile/compile_package.zig
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ const AtomicUsize = std.atomic.Value(usize);
const Mutex = std.Thread.Mutex;
const Condition = std.Thread.Condition;

/// Optional virtual file provider for overriding module sources.
pub const FileProvider = struct {
ctx: ?*anyopaque,
read: *const fn (ctx: ?*anyopaque, path: []const u8, gpa: Allocator) Allocator.Error!?[]u8,
};

/// Build execution mode
pub const Mode = enum { single_threaded, multi_threaded };

Expand Down Expand Up @@ -159,6 +165,8 @@ pub const PackageEnv = struct {
compiler_version: []const u8,
/// Builtin modules (Bool, Try, Str) for auto-importing in canonicalization (not owned)
builtin_modules: *const BuiltinModules,
/// Optional virtual file provider (owned by caller)
file_provider: ?FileProvider = null,

lock: Mutex = .{},
cond: Condition = .{},
Expand All @@ -185,7 +193,7 @@ pub const PackageEnv = struct {
total_type_checking_ns: u64 = 0,
total_check_diagnostics_ns: u64 = 0,

pub fn init(gpa: Allocator, package_name: []const u8, root_dir: []const u8, mode: Mode, max_threads: usize, sink: ReportSink, schedule_hook: ScheduleHook, compiler_version: []const u8, builtin_modules: *const BuiltinModules) PackageEnv {
pub fn init(gpa: Allocator, package_name: []const u8, root_dir: []const u8, mode: Mode, max_threads: usize, sink: ReportSink, schedule_hook: ScheduleHook, compiler_version: []const u8, builtin_modules: *const BuiltinModules, file_provider: ?FileProvider) PackageEnv {
return .{
.gpa = gpa,
.package_name = package_name,
Expand All @@ -196,6 +204,7 @@ pub const PackageEnv = struct {
.schedule_hook = schedule_hook,
.compiler_version = compiler_version,
.builtin_modules = builtin_modules,
.file_provider = file_provider,
.injector = std.ArrayList(Task).empty,
.modules = std.ArrayList(ModuleState).empty,
.discovered = std.ArrayList(ModuleId).empty,
Expand All @@ -213,6 +222,7 @@ pub const PackageEnv = struct {
schedule_hook: ScheduleHook,
compiler_version: []const u8,
builtin_modules: *const BuiltinModules,
file_provider: ?FileProvider,
) PackageEnv {
return .{
.gpa = gpa,
Expand All @@ -225,6 +235,7 @@ pub const PackageEnv = struct {
.schedule_hook = schedule_hook,
.compiler_version = compiler_version,
.builtin_modules = builtin_modules,
.file_provider = file_provider,
.injector = std.ArrayList(Task).empty,
.modules = std.ArrayList(ModuleState).empty,
.discovered = std.ArrayList(ModuleId).empty,
Expand Down Expand Up @@ -558,7 +569,7 @@ pub const PackageEnv = struct {
fn doParse(self: *PackageEnv, module_id: ModuleId) !void {
// Load source and init ModuleEnv
var st = &self.modules.items[module_id];
const src = std.fs.cwd().readFileAlloc(self.gpa, st.path, std.math.maxInt(usize)) catch |read_err| {
const src = self.readModuleSource(st.path) catch |read_err| {
// Note: Let the FileNotFound error propagate naturally
// The existing error handling will report it appropriately
return read_err;
Expand All @@ -582,6 +593,15 @@ pub const PackageEnv = struct {
try self.enqueue(module_id);
}

fn readModuleSource(self: *PackageEnv, path: []const u8) ![]u8 {
if (self.file_provider) |fp| {
if (try fp.read(fp.ctx, path, self.gpa)) |data| {
return data;
}
}
return std.fs.cwd().readFileAlloc(self.gpa, path, std.math.maxInt(usize));
}

fn doCanonicalize(self: *PackageEnv, module_id: ModuleId) !void {
var st = &self.modules.items[module_id];
var env = &st.env.?;
Expand Down
8 changes: 5 additions & 3 deletions src/lsp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ tail -f /tmp/roc-lsp-debug.log
---
```

Additional debug channels can be enabled with `--debug-build`, `--debug-syntax`, and `--debug-server`
which log build environment activity, syntax/type checking, and server lifecycle details respectively
to the same temporary log file.

## Editor examples

### Neovim (lua + nvim-lspconfig)
Expand All @@ -89,9 +93,7 @@ if not configs.roc_lsp then
name = 'roc_lsp',
cmd = { '/path/to/roc', 'experimental-lsp', '--debug-transport' },
filetypes = { 'roc' },
root_dir = function(fname)
return util.path.dirname(fname)
end,
root_markers = { 'main.roc', 'app.roc' },
single_file_support = true,
},
}
Expand Down
38 changes: 38 additions & 0 deletions src/lsp/diagnostics.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//! LSP diagnostic types for reporting errors, warnings, and hints to editors.

const std = @import("std");

/// The position of the diagnostic
pub const Position = struct {
line: u32,
character: u32,
};

/// The range of characters that the diagnostic applies to
pub const Range = struct {
start: Position,
end: Position,
};

/// Diagnostic expected by LSP specifications
pub const Diagnostic = struct {
range: Range,
severity: ?u32 = null,
source: ?[]const u8 = null,
message: []const u8,
};

/// Container for diagnostics destined for a single URI.
pub const PublishDiagnostics = struct {
uri: []u8,
diagnostics: []Diagnostic,

pub fn deinit(self: *PublishDiagnostics, allocator: std.mem.Allocator) void {
allocator.free(self.uri);
for (self.diagnostics) |diag| {
allocator.free(diag.message);
}
allocator.free(self.diagnostics);
self.* = undefined;
}
};
2 changes: 2 additions & 0 deletions src/lsp/handlers/did_change.zig
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ pub fn handler(comptime ServerType: type) type {
std.log.err("failed to apply full change for {s}: {s}", .{ uri, @errorName(err) });
};
}

self.onDocumentChanged(uri);
}

fn parseRange(value: std.json.Value) !DocumentStore.Range {
Expand Down
2 changes: 2 additions & 0 deletions src/lsp/handlers/did_open.zig
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ pub fn handler(comptime ServerType: type) type {
self.doc_store.upsert(uri, version, text) catch |err| {
std.log.err("failed to open {s}: {s}", .{ uri, @errorName(err) });
};

self.onDocumentChanged(uri);
}
};
}
5 changes: 3 additions & 2 deletions src/lsp/mod.zig
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ pub const transport = @import("transport.zig");
pub const server = @import("server.zig");

/// Convenience wrapper to launch the server using stdin/stdout from other modules.
pub fn runWithStdIo(allocator: std.mem.Allocator, debug_transport: bool) !void {
try server.runWithStdIo(allocator, debug_transport);
pub fn runWithStdIo(allocator: std.mem.Allocator, debug: server.DebugOptions) !void {
try server.runWithStdIo(allocator, debug);
}

test "lsp tests" {
std.testing.refAllDecls(@import("test/protocol_test.zig"));
std.testing.refAllDecls(@import("test/server_test.zig"));
std.testing.refAllDecls(@import("test/transport_test.zig"));
std.testing.refAllDecls(@import("test/document_store_test.zig"));
std.testing.refAllDecls(@import("test/syntax_test.zig"));
}
Loading