Skip to content
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
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
- [Configuration Sections](#configuration-sections)
- [Basic Configuration](#basic-configuration)
- [Path Configuration](#path-configuration)
- [Rename Configuration](#rename-configuration)
- [Usage](#-usage)
- [Code Completion](#code-completion)
- [Diagnostics](#diagnostics)
Expand Down Expand Up @@ -170,6 +171,9 @@ include_paths = ["foobar", "bazbaaz"] # Include paths to look for protofiles dur
[config.path]
clang_format = "clang-format"
protoc = "protoc"

[config.rename]
chain_rpc_request_response = false # Also rename <Rpc>Request/<Rpc>Response messages when renaming an rpc
```

### Configuration Sections
Expand Down Expand Up @@ -197,6 +201,16 @@ The `[config.path]` section contains path for various tools used by LSP.
- `clang_format`: Uses clang_format from this path for formatting
- `protoc`: Uses protoc from this path for diagnostics

#### Rename Configuration

The `[config.rename]` section tunes rename behaviour.

- `chain_rpc_request_response` (default `false`): when enabled, renaming an `rpc`
also renames its convention-named `<Rpc>Request` and `<Rpc>Response` messages —
and renaming such a message renames the `rpc` and its sibling message. The chain
only fires when the names follow the [Google API design guide](https://cloud.google.com/apis/design/naming_convention#request_and_response_messages)
convention and the messages are used by exactly one `rpc`.

---

## 🛠 Usage
Expand Down Expand Up @@ -233,7 +247,9 @@ Hover over any symbol or imports to get detailed documentation and comments asso

### Rename Symbols

Rename symbols like messages or enums, and Propagate the changes throughout the codebase. Currently, field renaming within symbols is not supported.
Rename symbols like messages, enums, services and RPC methods, and propagate the changes throughout the codebase. Rename also works when invoked on a type reference (e.g. the request or response type of an `rpc`) — the LSP pivots to the declaration and applies the rename from there. Field names, oneof names, and enum values can also be renamed at their declaration site (single-site rename, since they aren't referenced as types from other `.proto` files).

When an `rpc` follows the `rpc <Name>(<Name>Request) returns (<Name>Response)` convention from the [Google API design guide](https://google.aip.dev/) (AIPs 131–136), renaming any one of the three triggers a chained rename of the other two — but only when (a) the matching message name follows the convention exactly, (b) the request/response is used by exactly one rpc in the workspace, and (c) the user's new name preserves the convention. If any check fails, only the symbol the user invoked rename on is renamed.

### Find References

Expand Down
3 changes: 3 additions & 0 deletions protols.toml
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
[config]
include_paths = ["src/workspace/input"]

[config.rename]
chain_rpc_request_response = true
4 changes: 4 additions & 0 deletions sample/simple.proto
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ service BookService {
rpc GetBooksViaAuthor(GetBookViaAuthor) returns (stream Book) {}
rpc GetGreatestBook(stream GetBookRequest) returns (Book) {}
rpc GetBooks(stream GetBookRequest) returns (stream Book) {}
rpc ReadBook(ReadBookRequest) returns (ReadBookResponse) {}
}

message BookStore {
Expand All @@ -61,6 +62,9 @@ message BookStore {
EnumSample sample = 4;
}

message ReadBookRequest {}
message ReadBookResponse {}

// These are enum options representing some operation in the proto
// these are meant to be ony called from one place,

Expand Down
3 changes: 3 additions & 0 deletions src/config/input/protols-valid.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ include_paths = ["foobar", "bazbaaz"]
[config.path]
protoc = "/usr/bin/protoc"
clang_format = "/usr/bin/clang-format"

[config.rename]
chain_rpc_request_response = true
12 changes: 12 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub struct ProtolsConfig {
pub struct Config {
pub include_paths: Vec<String>,
pub path: PathConfig,
pub rename: RenameConfig,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
Expand All @@ -38,3 +39,14 @@ impl Default for PathConfig {
}
}
}

#[derive(Serialize, Deserialize, Debug, Clone, Default)]
#[serde(default)]
pub struct RenameConfig {
/// When renaming an `rpc`, also rename its convention-named
/// `<Rpc>Request` / `<Rpc>Response` messages — and, when renaming such a
/// message, the `rpc` and its sibling message. Opt-in: defaults to `false`
/// since not every codebase follows the `<Rpc>Request`/`Response` naming
/// convention.
pub chain_rpc_request_response: bool,
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ config:
path:
clang_format: clang-format
protoc: protoc
rename:
chain_rpc_request_response: false
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ config:
path:
clang_format: /usr/bin/clang-format
protoc: /usr/bin/protoc
rename:
chain_rpc_request_response: true
84 changes: 59 additions & 25 deletions src/lsp.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use std::ops::ControlFlow;
use std::{collections::HashMap, fs::read_to_string, path::PathBuf};
use std::{fs::read_to_string, path::PathBuf};
use tracing::{error, info, warn};

use async_lsp::lsp_types::{
Expand All @@ -20,6 +20,7 @@ use async_lsp::{Error, LanguageClient, ResponseError};
use futures::future::BoxFuture;
use serde_json::Value;

use crate::context::jumpable::Jumpable;
use crate::formatter::ProtoFormatter;
use crate::server::ProtoLanguageServer;
use crate::{docs, log};
Expand Down Expand Up @@ -244,7 +245,6 @@ impl ProtoLanguageServer {
) -> BoxFuture<'static, Result<Option<WorkspaceEdit>, ResponseError>> {
let uri = params.text_document_position.text_document.uri;
let pos = params.text_document_position.position;

let new_name = params.new_name;

let Some(tree) = self.state.get_tree(&uri) else {
Expand All @@ -253,38 +253,72 @@ impl ProtoLanguageServer {
};

let content = self.state.get_content(&uri);

let current_package = tree.get_package_name(content.as_bytes()).unwrap_or(".");
let ipath = self.configs.get_include_paths(&uri).unwrap_or_default();

let Some((edit, otext, ntext)) = tree.rename_tree(&pos, &new_name, content.as_bytes())
else {
error!(uri=%uri, "failed to rename in a tree");
return Box::pin(async move { Ok(None) });
// If the cursor is on a type reference (inside a message_or_enum_type
// node), pivot to the declaration and rename from there. The workspace
// pass then handles all references — including the one the user is
// standing on.
let (decl_uri, decl_pos) = match tree.rename_pivot_identifier(&pos, content.as_bytes()) {
Some(decl_path) => {
let locations =
self.state
.definition(&ipath, current_package, Jumpable::Identifier(decl_path));
let Some(decl) = locations.into_iter().next() else {
error!(uri=%uri, "failed to resolve declaration for reference-site rename");
return Box::pin(async move { Ok(None) });
};
(decl.uri, decl.range.start)
}
None => (uri.clone(), pos),
};

let Some(workspace) = self.configs.get_workspace_for_uri(&uri) else {
error!(uri=%uri, "failed to get workspace");
let Some(workspace) = self.configs.get_workspace_for_uri(&decl_uri) else {
error!(uri=%decl_uri, "failed to get workspace");
return Box::pin(async move { Ok(None) });
};
let Ok(workspace_path) = workspace.to_file_path() else {
error!(uri=%workspace, "workspace url is not a file path");
return Box::pin(async move { Ok(None) });
};

let work_done_token = params.work_done_progress_params.work_done_token;
let progress_sender = work_done_token.map(|token| self.with_report_progress(token));

let mut h = HashMap::new();
h.extend(self.state.rename_fields(
current_package,
&otext,
&ntext,
workspace.to_file_path().unwrap(),
progress_sender,
));
let progress_sender = params
.work_done_progress_params
.work_done_token
.map(|token| self.with_report_progress(token));

h.entry(tree.uri).or_insert(edit.clone()).extend(edit);
// The rpc/request/response chain rename is opt-in via the workspace's
// `[config.rename]` settings; without a config it stays off.
let chain_rpc_request_response = self
.configs
.get_config_for_uri(&uri)
.map(|c| c.config.rename.chain_rpc_request_response)
.unwrap_or_default();

let ops = self.state.compute_rename_ops(
&decl_uri,
decl_pos,
&new_name,
&ipath,
chain_rpc_request_response,
);
let Some(all_edits) = self
.state
.apply_rename_ops(&ops, workspace_path, progress_sender)
else {
error!(uri=%decl_uri, "failed to apply primary rename");
return Box::pin(async move { Ok(None) });
};

let response = Some(WorkspaceEdit {
changes: Some(h),
..Default::default()
});
let response = if all_edits.is_empty() {
None
} else {
Some(WorkspaceEdit {
changes: Some(all_edits),
..Default::default()
})
};

Box::pin(async move { Ok(response) })
}
Expand Down
25 changes: 25 additions & 0 deletions src/nodekind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,35 @@ impl NodeKind {
n.kind() == Self::FieldName.as_str()
}

pub fn is_rpc_name(n: &Node) -> bool {
n.kind() == Self::RpcName.as_str()
}

pub fn is_userdefined(n: &Node) -> bool {
n.kind() == Self::EnumName.as_str() || n.kind() == Self::MessageName.as_str()
}

pub fn is_renameable(n: &Node) -> bool {
Self::is_userdefined(n)
|| n.kind() == Self::ServiceName.as_str()
|| n.kind() == Self::RpcName.as_str()
|| n.kind() == Self::FieldName.as_str()
|| Self::is_field_decl_parent(n)
}

/// Kinds whose direct identifier child is the *name* of a field-like
/// declaration: regular fields, map fields, oneof fields, the oneof itself,
/// and enum values. For `string title = 1;`, the identifier `title` has
/// parent `field` — that's what we match here. The type identifier (e.g.
/// `Author` in `Author author = 2;`) is nested deeper under
/// `message_or_enum_type`, so it isn't caught by this predicate.
pub fn is_field_decl_parent(n: &Node) -> bool {
matches!(
n.kind(),
"field" | "map_field" | "oneof_field" | "oneof" | "enum_field"
)
}

pub fn is_actionable(n: &Node) -> bool {
n.kind() == Self::MessageName.as_str()
|| n.kind() == Self::EnumName.as_str()
Expand Down
22 changes: 22 additions & 0 deletions src/parser/input/test_rename_field.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
syntax = "proto3";

package com.parser;

enum Color {
RED = 0;
GREEN = 1;
}

message Author {
string name = 1;
}

message Book {
string title = 1;
Author author = 2;
map<string, int32> counts = 3;
oneof body {
string text = 4;
bytes blob = 5;
}
}
14 changes: 14 additions & 0 deletions src/parser/input/test_rename_service.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
syntax = "proto3";

package com.parser;

message Empty {}

message Book {
string title = 1;
}

service Library {
rpc GetBook(Empty) returns (Book);
rpc ListBooks(Empty) returns (Book);
}
Loading
Loading