Closes #59620 ## Problem A language whose name contains a `/`, such as a custom `PL/X` extension (`lsp_id` → `pl/x`), broke snippets end to end: - The `snippets: configure snippets` action wrote the file to `~/.config/zed/snippets/pl/x.json`, i.e. inside a `pl/` subdirectory. - The snippet scanner reads `snippets/` non-recursively and skips directories, so that file was never loaded and the snippet could never be used. - The completion lookup keyed off the raw `lsp_id` (`pl/x`), which wouldn't have matched the file-stem key even if the file had been scanned. So Zed's own UI created a snippet file it could never read back. ## Fix Add `LanguageName::snippet_scope_id()` (the `lsp_id` with `/` and `\` removed) and use it everywhere a language maps to its snippet file name or lookup key: - the Configure Snippets writer and its "already configured" label, and - the two completion lookups in `editor`. `PL/X` now maps to a flat `plx.json`, as suggested in the issue. The `editor::InsertSnippet` action is intentionally left unchanged: its `language` field is documented to be the snippet file name stem, which is already separator-free. Note: files created under the old behavior (nested `foo/bar.json`) aren't migrated; re-running Configure Snippets writes the corrected flat file. ## Testing - Added a `language_core` unit test asserting `snippet_scope_id()` strips `/` and `\` (e.g. `PL/X` → `plx`). - `cargo test -p language_core` passes. - `./script/clippy -p language_core -p language` passes. - Docs Prettier passes. Release Notes: - Fixed snippets being unusable for languages whose name contains a `/` character
143 lines
3.5 KiB
Rust
143 lines
3.5 KiB
Rust
use gpui_shared_string::SharedString;
|
|
use schemars::JsonSchema;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::{
|
|
borrow::Borrow,
|
|
sync::atomic::{AtomicUsize, Ordering::SeqCst},
|
|
};
|
|
|
|
static NEXT_LANGUAGE_ID: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
|
|
pub struct LanguageId(usize);
|
|
|
|
impl LanguageId {
|
|
pub fn new() -> Self {
|
|
Self(NEXT_LANGUAGE_ID.fetch_add(1, SeqCst))
|
|
}
|
|
}
|
|
|
|
impl Default for LanguageId {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[derive(
|
|
Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
|
|
)]
|
|
pub struct LanguageName(pub SharedString);
|
|
|
|
impl LanguageName {
|
|
pub fn new(s: &str) -> Self {
|
|
Self(SharedString::new(s))
|
|
}
|
|
|
|
pub fn new_static(s: &'static str) -> Self {
|
|
Self(SharedString::new_static(s))
|
|
}
|
|
|
|
pub fn from_proto(s: String) -> Self {
|
|
Self(SharedString::from(s))
|
|
}
|
|
|
|
pub fn to_proto(&self) -> String {
|
|
self.0.to_string()
|
|
}
|
|
|
|
pub fn lsp_id(&self) -> String {
|
|
match self.0.as_ref() {
|
|
"Plain Text" => "plaintext".to_string(),
|
|
language_name => language_name.to_lowercase(),
|
|
}
|
|
}
|
|
|
|
/// Identifier used to name and look up this language's snippet file.
|
|
///
|
|
/// Path separators are stripped from the `lsp_id` because the snippet file
|
|
/// is stored as a flat file directly under the snippets directory; a `/`
|
|
/// (or `\`) would otherwise place it in a subdirectory that is never
|
|
/// scanned, making the snippet impossible to use.
|
|
pub fn snippet_scope_id(&self) -> String {
|
|
self.lsp_id().replace(['/', '\\'], "")
|
|
}
|
|
}
|
|
|
|
impl From<LanguageName> for SharedString {
|
|
fn from(value: LanguageName) -> Self {
|
|
value.0
|
|
}
|
|
}
|
|
|
|
impl From<SharedString> for LanguageName {
|
|
fn from(value: SharedString) -> Self {
|
|
LanguageName(value)
|
|
}
|
|
}
|
|
|
|
impl AsRef<str> for LanguageName {
|
|
fn as_ref(&self) -> &str {
|
|
self.0.as_ref()
|
|
}
|
|
}
|
|
|
|
impl Borrow<str> for LanguageName {
|
|
fn borrow(&self) -> &str {
|
|
self.0.as_ref()
|
|
}
|
|
}
|
|
|
|
impl PartialEq<str> for LanguageName {
|
|
fn eq(&self, other: &str) -> bool {
|
|
self.0.as_ref() == other
|
|
}
|
|
}
|
|
|
|
impl PartialEq<&str> for LanguageName {
|
|
fn eq(&self, other: &&str) -> bool {
|
|
self.0.as_ref() == *other
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for LanguageName {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
write!(f, "{}", self.0)
|
|
}
|
|
}
|
|
|
|
impl From<&'static str> for LanguageName {
|
|
fn from(str: &'static str) -> Self {
|
|
Self(SharedString::new_static(str))
|
|
}
|
|
}
|
|
|
|
impl From<LanguageName> for String {
|
|
fn from(value: LanguageName) -> Self {
|
|
let value: &str = &value.0;
|
|
Self::from(value)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn snippet_scope_id_strips_path_separators() {
|
|
// A `/` or `\` in the language name would route the snippet file into a
|
|
// subdirectory that is never scanned (see issue #59620).
|
|
assert_eq!(LanguageName::new("PL/X").snippet_scope_id(), "plx");
|
|
assert_eq!(LanguageName::new("a\\b").snippet_scope_id(), "ab");
|
|
// Names without separators are unchanged beyond `lsp_id`'s lowercasing.
|
|
assert_eq!(LanguageName::new("Rust").snippet_scope_id(), "rust");
|
|
assert_eq!(
|
|
LanguageName::new("Shell Script").snippet_scope_id(),
|
|
"shell script"
|
|
);
|
|
assert_eq!(
|
|
LanguageName::new("Plain Text").snippet_scope_id(),
|
|
"plaintext"
|
|
);
|
|
}
|
|
}
|