Files
zed/crates/language_core/src/language_name.rs
T
Ibrahim Khan cbe92a9c1a snippets: Strip path separators from language snippet file names (#61421)
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
2026-07-23 04:49:39 +00:00

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"
);
}
}