Skip to content

feat: Infer gem info from standard filesystem layout. #155

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Nov 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 13 additions & 9 deletions docs/scip-ruby/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,14 @@ instead (`git rev-parse HEAD`).

## `--gem-map-path <arg>`

At the moment, scip-ruby requires an extra step for cross-repo
code navigation; you need to supply information about which
file belongs to which gem explicitly, using a newline-delimited
JSON file in the following format:
For cross-repo navigation, scip-ruby needs to know which files
belong to which gem. By default, scip-ruby will attempt to
infer this from filepaths, assuming that files under `sorbet/rbi/{gems,annotations,dsl}/`
belong to external gems as per the [standard layout](https://sorbet.org/docs/rbi#quickref).

If you have files that contain definitions belonging to other gems,
but are placed in non-standard directories, you can specify the correct gem information
using a newline-delimited JSON file in the following format:

<!--
TODO: Uncomment this
Expand All @@ -46,16 +50,16 @@ newline-delimited JSON file in the following format:
...
```

Then pass the path to the JSON file:
Pass the path to the JSON file:

```bash
scip-ruby --gem-map-path path/to/cross-repo-metadata.json
```

Paths are interpreted relative to the working directory
Paths in the JSON file are interpreted relative to the working directory
for the `scip-ruby` invocation.

If information about the gem being indexed
If information about the primary gem being indexed (i.e. the one corresponding to the project root)
cannot be inferred from the filesystem, then you can supply
the `--gem-metadata` argument as described earlier.

Expand All @@ -69,11 +73,11 @@ and paths created by traversing directories.

The path for emitting the SCIP index. Defaults to `index.scip`.

## `--unquiet-errors`
## `--unquiet`

scip-ruby defaults to running in Sorbet's quiet mode, as scip-ruby supports
indexing `# typed: false` files on a best-effort basis, but Sorbet may
rightfully flag many errors in those files. The number of errors can be
overwhelming if there is a large amount of untyped code.

This flag restores Sorbet's default behavior.
This flag restores Sorbet's default behavior.
38 changes: 38 additions & 0 deletions scip_indexer/SCIPGemMetadata.cc
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,31 @@ GemMapping::GemMapping()
: currentGem(), map(), stdlibGem(make_shared<GemMetadata>(GemMetadata::tryParse("ruby@latest").value())),
globalPlaceholderGem(make_shared<GemMetadata>(GemMetadata::tryParse("_global_@latest").value())) {}

optional<shared_ptr<GemMetadata>> tryParseFilepath(std::string_view filepath) {
auto filenameStartIndex = filepath.find_last_of('/') + 1;
// E.g. foo/bar,
// 0123456 ; startIndex = 4, size = 7
auto filename = filepath.substr(filenameStartIndex, filepath.size() - filenameStartIndex);
auto dotIndex = filename.find_last_of('.');
if (dotIndex == std::string::npos) {
// Should have .rbi files here
return nullopt;
}
auto basename = filename.substr(0, dotIndex);
if (absl::StrContains(basename, '@')) {
auto metadata = GemMetadata::tryParse(basename);
ENFORCE(metadata.has_value());
return make_shared<GemMetadata>(metadata.value());
}
// TODO: Can we do better here by getting dependency versions from somewhere
// else, like Gemfile.lock? In practice, it looks like files under gems/ have associated
// versions, whereas files under annotations/ and gems/ do not have versions.
// Another potential option is to do two passes over the list of files; first collecting
// all external gem names+versions based on sorbet/rbi/gems, and then checking
// if the name here matches any known external gem name.
return make_shared<GemMetadata>(GemMetadata::forTest(string(basename), "latest"));
}

optional<shared_ptr<GemMetadata>> GemMapping::lookupGemForFile(const core::GlobalState &gs, core::FileRef file) const {
ENFORCE(file.exists());
auto it = this->map.find(file);
Expand All @@ -197,6 +222,19 @@ optional<shared_ptr<GemMetadata>> GemMapping::lookupGemForFile(const core::Globa
if (absl::StartsWith(filepath, core::File::URL_PREFIX)) {
return this->stdlibGem;
}
// See https://sorbet.org/docs/rbi#quickref for description of the standard layout.
// Based on some Sourcegraph searches, it looks like RBI files can be named either
// gem_name.rbi or [email protected].
if (absl::StrContains(filepath, "sorbet/rbi/")) {
if (absl::StrContains(filepath, "sorbet/rbi/gems/") || absl::StrContains(filepath, "sorbet/rbi/annotations/") ||
absl::StrContains(filepath, "sorbet/rbi/dsl/")) {
auto metadata = tryParseFilepath(filepath);
if (metadata.has_value()) {
return metadata;
}
}
// hidden-definitions and todo.rbi get treated as part of the current gem
}
if (this->currentGem.has_value()) {
// TODO Should we enforce here in debug builds?
// Fallback to this if set, to avoid collisions with other gems.
Expand Down
7 changes: 4 additions & 3 deletions scip_indexer/SCIPGemMetadata.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <utility>

#include "absl/strings/str_split.h"
Expand Down Expand Up @@ -47,12 +48,12 @@ class GemMetadata final {
return GemMetadata(name, version);
}

static std::optional<GemMetadata> tryParse(const std::string &nameAndVersion) {
std::vector<std::string> v = absl::StrSplit(nameAndVersion, '@');
static std::optional<GemMetadata> tryParse(const std::string_view nameAndVersion) {
std::vector<std::string_view> v = absl::StrSplit(nameAndVersion, '@');
if (v.size() != 2 || v[0].empty() || v[1].empty()) {
return std::nullopt;
}
return GemMetadata{v[0], v[1]};
return GemMetadata{std::string(v[0]), std::string(v[1])};
}

const std::string &name() const {
Expand Down
7 changes: 7 additions & 0 deletions test/scip/testdata/multifile/infer-gem-map/current_gem.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# typed: true

module CurrentGem
def gem_fun
puts 'Hello World'
end
end
5 changes: 5 additions & 0 deletions test/scip/testdata/multifile/infer-gem-map/current_gem.rbi
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# typed: true

module CurrentGem
def gem_fun; end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# typed: true

module CurrentGem
# ^^^^^^^^^^ definition currentgem 77 CurrentGem#
def gem_fun
# ^^^^^^^ definition currentgem 77 CurrentGem#gem_fun().
puts 'Hello World'
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# typed: true

module CurrentGem
# ^^^^^^^^^^ definition currentgem 77 CurrentGem#
def gem_fun; end
# ^^^^^^^ definition currentgem 77 CurrentGem#gem_fun().
end
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# gem-metadata: currentgem@77
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# typed: true

module AnnotGem
def annot_fun; end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# typed: true

module AnnotGem
# ^^^^^^^^ definition annotgem latest AnnotGem#
def annot_fun; end
# ^^^^^^^^^ definition annotgem latest AnnotGem#annot_fun().
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# typed: true

module DSLGem
def create; end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# typed: true

module DSLGem
# ^^^^^^ definition railsgem latest DSLGem#
def create; end
# ^^^^^^ definition railsgem latest DSLGem#create().
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# typed: true

module ExternalGem
def ext_fun; end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# typed: true

module ExternalGem
# ^^^^^^^^^^^ definition extgem 99 ExternalGem#
def ext_fun; end
# ^^^^^^^ definition extgem 99 ExternalGem#ext_fun().
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# typed: true

module Hidden
def hidden_fun; end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# typed: true

module Hidden
# ^^^^^^ definition currentgem 77 Hidden#
def hidden_fun; end
# ^^^^^^^^^^ definition currentgem 77 Hidden#hidden_fun().
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# typed: true

module TODO
TODO_CONSTANT = 1
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# typed: true

module TODO
# ^^^^ definition currentgem 77 TODO#
TODO_CONSTANT = 1
# ^^^^^^^^^^^^^ definition currentgem 77 TODO#TODO_CONSTANT.
# ^^^^^^^^^^^^^^^^^ reference currentgem 77 TODO#TODO_CONSTANT.
end
45 changes: 45 additions & 0 deletions test/scip_test_runner.cc
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,51 @@ TEST_CASE("GemMapParsing") {
ENFORCE(gemMap.lookupGemForFile(gs, unnormalized2).has_value());
}

// This test might feel a little redundant since we also have a similar integration test.
// However, the integration test is making sure that the emitted index has the right
// gem info, which is not checked by this test.
TEST_CASE("GemInference") {
if (!onlyRunUnitTests) {
return;
}
vector<unique_ptr<core::Error>> errors;

auto logger = spdlog::stderr_color_mt("gem-inference-test");
auto errorCollector = make_shared<core::ErrorCollector>();
auto errorQueue = make_shared<core::ErrorQueue>(*logger, *logger, errorCollector);
core::GlobalState gs(errorQueue);
gs.initEmpty(); // needed for proper file table access

core::FileRef externalGemRBI, annotationsRBI, dslRBI, todoRBI, hiddenDefsRBI, notSorbetRBI;
{
core::UnfreezeFileTable fileTableAccess(gs);
externalGemRBI = gs.enterFile("./sorbet/rbi/gems/[email protected]", "");
annotationsRBI = gs.enterFile("./sorbet/rbi/annotations/myannot.rbi", "");
dslRBI = gs.enterFile("sorbet/rbi/dsl/myrails.rbi", "");
todoRBI = gs.enterFile("./sorbet/rbi/todo.rbi", "");
hiddenDefsRBI = gs.enterFile("./sorbet/rbi/hidden-definitions/hidden.rbi", "");
notSorbetRBI = gs.enterFile("myproject/x.rbi", "");
}

scip_indexer::GemMapping gemMap{};
gemMap.markCurrentGem(scip_indexer::GemMetadata::forTest("mygem", "33"));

auto checkGem = [&](core::FileRef file, string name, string version) {
auto gem = gemMap.lookupGemForFile(gs, file);
ENFORCE(gem.has_value());
ENFORCE(*gem->get() == scip_indexer::GemMetadata::forTest(name, version),
"\nexpected: name {}, version {}\nobtained: name {}, version {}", name, version, gem->get()->name(),
gem->get()->version());
};

checkGem(externalGemRBI, "extgem", "0");
checkGem(annotationsRBI, "myannot", "latest");
checkGem(dslRBI, "myrails", "latest");
checkGem(todoRBI, "mygem", "33");
checkGem(hiddenDefsRBI, "mygem", "33");
checkGem(notSorbetRBI, "mygem", "33");
}

// Copied from pipeline_test_runner.cc
class CFGCollectorAndTyper { // TODO(varun): Copy this over to scip_test_runner.cc
public:
Expand Down