Skip to content

Commit

Permalink
Merge pull request #96 from elbywan/projects-feature
Browse files Browse the repository at this point in the history
feat: projects
  • Loading branch information
elbywan authored Sep 15, 2024
2 parents f0f8e93 + 27f5559 commit f6dbe7c
Show file tree
Hide file tree
Showing 8 changed files with 206 additions and 65 deletions.
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,19 @@ use it as the entry point.
require "./spec/**"
```

### Multiple projects

If you have multiple Crystal projects in a single folder (e.g. a monorepo), you can add a `projects` field in the root `shard.yml` file, containing an array of paths or globs to the underlying Crystal projects:

```yml
crystalline:
projects:
- projects/my_project_1
- workspaces/**
```

Each of these projects must contain the `shard.yml`, ideally with the entry point as mentioned above. However, even if no entry point is present, `require`s will still be resolved relative to the project directory rather than the root directory.

## Features

**Disclaimer: `Crystalline` is not as extensive in terms of features as other
Expand Down Expand Up @@ -371,7 +384,7 @@ LSP::Log.info { "log" }
```

Debug logs are deactivated by default, uncomment this line in
`src/crystalline/lsp/server.cr` to enable them:
`src/crystalline/main.cr` to enable them:

```crystal
# Uncomment:
Expand Down
16 changes: 13 additions & 3 deletions src/crystalline/analysis/analysis.cr
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,25 @@ require "./submodule_visitor"

module Crystalline::Analysis
# Compile a target *file_uri*.
def self.compile(server : LSP::Server, file_uri : URI, *, file_overrides : Hash(String, String)? = nil, ignore_diagnostics = false, wants_doc = false, fail_fast = false, top_level = false)
def self.compile(server : LSP::Server, file_uri : URI, *, lib_path : String? = nil, file_overrides : Hash(String, String)? = nil, ignore_diagnostics = false, wants_doc = false, fail_fast = false, top_level = false)
if file_uri.scheme == "file"
file = File.new file_uri.decoded_path
sources = [
Crystal::Compiler::Source.new(file_uri.decoded_path, file.gets_to_end),
]
file.close
self.compile(server, sources, file_overrides: file_overrides, ignore_diagnostics: ignore_diagnostics, wants_doc: wants_doc, top_level: top_level)
self.compile(server, sources, lib_path: lib_path, file_overrides: file_overrides, ignore_diagnostics: ignore_diagnostics, wants_doc: wants_doc, top_level: top_level)
end
end

# Compile an array of *sources*.
def self.compile(server : LSP::Server, sources : Array(Crystal::Compiler::Source), *, file_overrides : Hash(String, String)? = nil, ignore_diagnostics = false, wants_doc = false, fail_fast = false, top_level = false)
def self.compile(server : LSP::Server, sources : Array(Crystal::Compiler::Source), *, lib_path : String? = nil, file_overrides : Hash(String, String)? = nil, ignore_diagnostics = false, wants_doc = false, fail_fast = false, top_level = false)
diagnostics = Diagnostics.new
reply_channel = Channel(Crystal::Compiler::Result | Exception).new

# LSP::Log.info { "sources: #{sources.map(&.filename)}" }
# LSP::Log.info { "lib_path: #{lib_path}" }

# Delegate heavy processing to a separate thread.
Thread.new do
wait_before_termination = Channel(Nil).new
Expand All @@ -33,6 +36,13 @@ module Crystalline::Analysis
compiler.wants_doc = wants_doc
compiler.stdout = dev_null
compiler.stderr = dev_null

if lib_path_override = lib_path
path = Crystal::CrystalPath.default_path_without_lib.split(Process::PATH_DELIMITER)
path.insert(0, lib_path_override)
compiler.crystal_path = Crystal::CrystalPath.new(path)
end

reply = begin
if top_level
# Top level only.
Expand Down
6 changes: 5 additions & 1 deletion src/crystalline/controller.cr
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ class Crystalline::Controller
def when_ready : Nil
# Compile the workspace at once.
spawn same_thread: true do
workspace.compile(@server)
workspace.projects.each do |p|
if entry_point = p.entry_point?
workspace.compile(@server, entry_point)
end
end
end
end

Expand Down
25 changes: 25 additions & 0 deletions src/crystalline/ext/compiler.cr
Original file line number Diff line number Diff line change
Expand Up @@ -198,4 +198,29 @@ module Crystal
}
end
end

struct CrystalPath
# Adds functionality to get the CRYSTAL_PATH value, but without the default
# library directory.
def self.default_path_without_lib
parts = self.default_path.split(Process::PATH_DELIMITER)
parts.select(&.!=(DEFAULT_LIB_PATH)).join(Process::PATH_DELIMITER)
end
end

class Program
# Make it possible to use a custom library path.
setter crystal_path
end

class Compiler
# Make it possible to use a custom library path with the Program.
property crystal_path = Crystal::CrystalPath.new

private def new_program(sources)
program = previous_def
program.crystal_path = crystal_path
program
end
end
end
5 changes: 3 additions & 2 deletions src/crystalline/main.cr
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ module Crystalline
hover_provider: true,
definition_provider: true,
document_symbol_provider: true,
# signature_help_provider: LSP::SignatureHelpOptions.new(
# signature_help_provider: LSP::SignatureHelpOptions.new(
# trigger_characters: ["(", " "]
# ),
)
)

module EnvironmentConfig
# Add the `crystal env` environment variables to the current env.
Expand All @@ -47,6 +47,7 @@ module Crystalline

def self.init(*, input : IO = STDIN, output : IO = STDOUT)
EnvironmentConfig.run
# ::Log.setup(:debug, LSP::Log.backend.not_nil!)
server = LSP::Server.new(input, output, SERVER_CAPABILITIES)
Controller.new(server)
rescue ex
Expand Down
93 changes: 93 additions & 0 deletions src/crystalline/project.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
require "yaml"

class Crystalline::Project
# The project root filesystem uri.
getter root_uri : URI
# The dependencies of the project, meaning the list of files required by the compilation target (entry point).
property dependencies : Set(String) = Set(String).new
# Determines the project entry point.
getter? entry_point : URI? do
path = Path[root_uri.decoded_path, "shard.yml"]
shards_yaml = File.open(path) do |file|
YAML.parse(file)
end
shard_name = shards_yaml["name"].as_s
# If shard.yml has the `crystalline/main` key, use that.
relative_main = shards_yaml.dig?("crystalline", "main").try &.as_s
# Else if shard.yml has a `targets/[shard name]/main` key, use that.
relative_main ||= shards_yaml.dig?("targets", shard_name, "main").try &.as_s
if relative_main && File.exists? Path[root_uri.decoded_path, relative_main]
main_path = Path[root_uri.decoded_path, relative_main]
# Add the entry point as a dependency to itself.
dependencies << main_path.to_s
URI.parse("file://#{main_path}")
end
rescue e
nil
end

def initialize(@root_uri)
end

# Finds and returns an array of all projects in the workspace root.
def self.find_in_workspace_root(workspace_root_uri : URI) : Array(Project)
root_project = Project.new(workspace_root_uri)
# First, check for a Crystalline project file.
begin
path = Path[workspace_root_uri.decoded_path, "shard.yml"]
shards_yaml = File.open(path) do |file|
YAML.parse(file)
end

projects = shards_yaml.dig?("crystalline", "projects").try do |pjs|
Dir.glob(pjs.as_a.map(&.as_s)).reduce([] of Project) do |acc, match|
path = Path.new(match)

is_directory = File.directory?(path)
has_shard_yml = is_directory && File.exists?(Path[path, "shard.yml"])
is_not_lib = has_shard_yml && path.parent != "lib"

if is_directory && has_shard_yml && is_not_lib
normalized_path = Path[workspace_root_uri.decoded_path, path].normalize
acc << Project.new(URI.parse("file://#{normalized_path}"))
else
acc
end
end
end || [] of Project

projects << root_project
rescue e
# Failing that, create a project for the workspace root.
[root_project]
end
end

# Finds the path-wise distance to the given file URI. If the file URI is not a
# dependency of this workspace's entry point, returns nil.
def distance_to_dependency(file_uri : URI) : Int32?
file_path = file_uri.decoded_path
relative = Path[file_uri.decoded_path].relative_to?(root_uri.decoded_path)

# If we can't get a relative path, give it the maximum distance possible, so
# it's the lowest priority.
return Int32::MAX if relative.nil?

relative.parts.size
end

# Path to the shards "lib" path for this project.
def default_lib_path
Path[@root_uri.decoded_path, "lib"].to_s
end

# Finds the best-fitting project to use for the given file.
def self.best_fit_for_file(projects : Array(Project), file_uri : URI) : Project?
project_distances = projects.compact_map do |p|
distance = p.distance_to_dependency(file_uri)
{p, distance} if distance
end

project_distances.sort_by(&.[1]).first?.try(&.[0])
end
end
5 changes: 3 additions & 2 deletions src/crystalline/text_document.cr
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
require "priority-queue"
require "uri"
require "./project"

class Crystalline::TextDocument
getter uri : URI
@inner_contents : Array(String) = [] of String
getter! version : Int32
@pending_changes : Priority::Queue({String, LSP::Range}) = Priority::Queue({String, LSP::Range}).new
getter? project : Project?

def initialize(uri : String, contents : String)
@uri = URI.parse(uri)
def initialize(@uri, @project, contents : String)
self.contents = contents
end

Expand Down
Loading

0 comments on commit f6dbe7c

Please sign in to comment.