Skip to main content

strop_engine/editor/
lsp.rs

1//! Editor-side LSP event handling and asynchronous navigation. Local
2//! and remote documents share the request/server/incarnation/revision
3//! ownership; a remote workspace adds the endpoint to every identity —
4//! diagnostics, bindings and navigation never alias a remote path onto
5//! the local disk (0036 RW8).
6
7use super::{trace, Editor};
8use std::path::{Path, PathBuf};
9use std::sync::mpsc::Receiver;
10
11use strop_lsp::protocol::ResolvedDiag;
12use strop_lsp::registry;
13use strop_lsp::{LspEvent, ServerId};
14use strop_workspace::{Filesystem, ResourceLocation};
15
16pub(crate) mod attach;
17mod lifecycle;
18pub(crate) mod remote;
19pub(crate) mod state;
20#[cfg(test)]
21mod tests;
22
23pub struct LspServer {
24    pub id: ServerId,
25    /// None for replayed servers: identity and replies come from the
26    /// injected record/event stream — never a fake client.
27    pub client: Option<strop_lsp::Client>,
28    pub rx: Receiver<LspEvent>,
29    pub ready: bool,
30}
31
32impl Editor {
33    /// The current document's path identity: a local absolute path, or
34    /// the canonical remote file's endpoint-scoped path. Remote
35    /// windows must be complete for language services (0036 RW8) —
36    /// partial/follow windows refuse, they never pretend.
37    pub(super) fn lsp_current_doc_path(&self) -> Option<ResourceLocation> {
38        if self.cur().remote_metadata().is_some() && !self.remote_window_complete() {
39            return None;
40        }
41        self.lsp_doc_path(self.current())
42    }
43
44    fn lsp_doc_path(&self, document: strop_core::id::DocumentId) -> Option<ResourceLocation> {
45        let document = self.docs.get(document)?;
46        match &document.source {
47            crate::editor::document::DocumentSource::Remote(file) => {
48                Some(ResourceLocation::remote(
49                    file.file.endpoint().clone(),
50                    file.file.path().to_path_buf(),
51                ))
52            }
53            crate::editor::document::DocumentSource::Container { container, path } => {
54                Some(ResourceLocation {
55                    filesystem: strop_workspace::Filesystem::Container(container.clone()),
56                    path: path.clone(),
57                })
58            }
59            _ => document
60                .buf
61                .path
62                .as_ref()
63                .map(|path| ResourceLocation::local(self.cwd.join(path))),
64        }
65    }
66
67    /// A canonical remote file on `endpoint`, when any open document
68    /// still owns one — the `with_path` seed for remote navigation.
69    pub(super) fn remote_file_for(
70        &self,
71        endpoint: &strop_workspace::RemoteEndpoint,
72    ) -> Option<strop_workspace::RemoteFile> {
73        self.docs.iter().find_map(|(_, document)| {
74            match &document.source {
75                crate::editor::document::DocumentSource::Remote(source) => Some(&source.file),
76                _ => None,
77            }
78            .filter(|file| file.endpoint() == endpoint)
79            .cloned()
80        })
81    }
82
83    pub(crate) fn handle_lsp_event(&mut self, event: LspEvent) {
84        trace::services::lsp(&event);
85        match event {
86            LspEvent::Ready { server, name } => {
87                if let Some(owner) = self.lsp_servers.iter_mut().find(|owner| owner.id == server) {
88                    owner.ready = true;
89                    // Success must not erase a configuration warning
90                    // (0033 §2): readiness is reported alongside it.
91                    self.message = match self.layer_warning() {
92                        Some(warning) => format!("lsp: {name} ready — {warning}"),
93                        None => format!("lsp: {name} ready"),
94                    };
95                }
96            }
97            LspEvent::Failed { server, name, hint } => {
98                if self.lsp_servers.iter().any(|s| s.id == server) {
99                    self.lsp_failed(server);
100                    self.message = format!("lsp: {name} failed — {hint}");
101                } else {
102                    trace::services::rejected("lsp", "failure for an unowned server");
103                }
104            }
105            LspEvent::ServerMessage { server, name, text } => {
106                if self.lsp_servers.iter().any(|s| s.id == server) {
107                    self.message = format!("lsp: {name}: {text}");
108                } else {
109                    trace::services::rejected("lsp", "message for an unowned server");
110                }
111            }
112            LspEvent::Diagnostics {
113                context,
114                doc,
115                diags,
116            } => {
117                let valid = self
118                    .lsp_state
119                    .bindings
120                    .get(&context.document)
121                    .is_some_and(|b| {
122                        b.server == context.server
123                            && b.path == doc.path
124                            && b.target == doc.filesystem
125                            && b.revision == context.revision
126                    });
127                let Some(doc_buffer) = self
128                    .docs
129                    .get(context.document)
130                    .filter(|d| valid && d.buf.revision() == context.revision)
131                else {
132                    trace::services::rejected("lsp", "diagnostic owner/revision changed");
133                    return;
134                };
135                let buffer = &doc_buffer.buf;
136                let resolved: Vec<ResolvedDiag> = diags
137                    .into_iter()
138                    .map(|d| d.resolve(context.encoding, buffer))
139                    .collect();
140                self.diags.insert(
141                    context.document,
142                    super::diagnostics::DocumentDiagnostics {
143                        revision: context.revision,
144                        items: resolved,
145                    },
146                );
147            }
148            LspEvent::HoverText { context, text } => {
149                if !self.finish_lsp_reply(&context) {
150                    trace::services::rejected(
151                        "lsp",
152                        "hover request/server/document/revision changed",
153                    );
154                    return;
155                }
156                self.hover_card = Some(text);
157            }
158            LspEvent::Note { context, text } => {
159                if !self.finish_lsp_reply(&context) {
160                    trace::services::rejected(
161                        "lsp",
162                        "navigation request/server/document/revision changed",
163                    );
164                    return;
165                }
166                self.message = text;
167            }
168            LspEvent::Edits { context, edits } => {
169                if !self.finish_lsp_reply(&context) {
170                    trace::services::rejected("lsp", "edit request owner/revision changed");
171                    return;
172                }
173                if edits.is_empty() {
174                    self.message = "already formatted".into();
175                    return;
176                }
177                let Some(location) =
178                    self.lsp_state
179                        .bindings
180                        .get(&context.stamp.document)
181                        .map(|binding| ResourceLocation {
182                            filesystem: binding.target.clone(),
183                            path: binding.path.clone(),
184                        })
185                else {
186                    trace::services::rejected("lsp", "edits for an unbound document");
187                    return;
188                };
189                let plan = self.build_change_plan(
190                    super::changes::ChangeProducer::Format,
191                    vec![(location, edits)],
192                    context.encoding,
193                );
194                self.apply_change_plan(plan);
195            }
196            LspEvent::WorkspaceEdits { context, edits } => {
197                if !self.finish_lsp_reply(&context) {
198                    trace::services::rejected("lsp", "workspace-edit owner/revision changed");
199                    return;
200                }
201                if edits.is_empty() {
202                    self.message = format!("lsp: {} made no edits", context.kind.label());
203                    return;
204                }
205                let producer = match context.kind {
206                    strop_lsp::RequestKind::Rename => super::changes::ChangeProducer::Rename,
207                    _ => super::changes::ChangeProducer::CodeAction,
208                };
209                let plan = self.build_change_plan(producer, edits, context.encoding);
210                self.apply_change_plan(plan);
211            }
212            LspEvent::Symbols { context, symbols } => {
213                if !self.finish_lsp_reply(&context) {
214                    trace::services::rejected("lsp", "symbol owner/revision changed");
215                    return;
216                }
217                if symbols.is_empty() {
218                    self.message = "no symbols in this document".into();
219                    return;
220                }
221                use strop_picker::{Item, Payload};
222                let items = symbols
223                    .into_iter()
224                    .filter_map(|symbol| {
225                        let line = symbol.location.position.line.get() + 1;
226                        let col = symbol.location.position.column.get() + 1;
227                        let path = symbol.location.doc.path.clone();
228                        let payload = match symbol.location.doc.filesystem {
229                            strop_workspace::Filesystem::Local => Payload::Grep {
230                                path,
231                                line,
232                                col,
233                                match_len: 1,
234                                line_text: String::new(),
235                            },
236                            strop_workspace::Filesystem::Remote(endpoint) => Payload::Remote {
237                                endpoint,
238                                path,
239                                line,
240                                col,
241                            },
242                            // No container LSP is wired (DC1a); drop with a
243                            // trace rather than aliasing a local path.
244                            strop_workspace::Filesystem::Container(_) => {
245                                trace::services::rejected("lsp", "container symbol dropped");
246                                return None;
247                            }
248                        };
249                        let text = if symbol.container.is_empty() {
250                            format!("{}  · {} · :{}", symbol.name, symbol.kind, line)
251                        } else {
252                            format!(
253                                "{}  {} · {} · :{}",
254                                symbol.name, symbol.container, symbol.kind, line
255                            )
256                        };
257                        Some(Item { text, payload })
258                    })
259                    .collect();
260                self.open_picker(strop_picker::Kind::Symbols);
261                if let Some(glue) = self.picker.as_mut() {
262                    glue.picker.append(items);
263                }
264                // Items landed after the initial (empty-catalog)
265                // ranking: re-rank or the list renders empty.
266                self.request_picker_ranking();
267            }
268            LspEvent::ActionList { context, actions } => {
269                if !self.finish_lsp_reply(&context) {
270                    trace::services::rejected("lsp", "code-action owner/revision changed");
271                    return;
272                }
273                if actions.is_empty() {
274                    self.message = "no code actions here".into();
275                    return;
276                }
277                let items = actions
278                    .iter()
279                    .enumerate()
280                    .map(|(index, action)| strop_picker::Item {
281                        text: action.title.clone(),
282                        payload: strop_picker::Payload::CodeAction(index),
283                    })
284                    .collect();
285                self.changes.pending_actions = actions;
286                self.open_picker(strop_picker::Kind::CodeActions);
287                self.changes.pending_encoding = context.encoding;
288                if let Some(glue) = self.picker.as_mut() {
289                    glue.picker.append(items);
290                }
291                // Same post-append re-rank as the symbols arm above:
292                // the initial ranking ran over an empty catalog.
293                self.request_picker_ranking();
294            }
295            LspEvent::GotoLocation { context, location } => {
296                if !self.finish_lsp_reply(&context) {
297                    trace::services::rejected(
298                        "lsp",
299                        "navigation request/server/document/revision changed",
300                    );
301                    return;
302                }
303                self.jump_to_location(location, context);
304            }
305            LspEvent::Locations {
306                context,
307                kind,
308                items,
309            } => {
310                if !self.finish_lsp_reply(&context) {
311                    trace::services::rejected("lsp", "location-list owner changed");
312                    return;
313                }
314                match items.len() {
315                    0 => {
316                        self.message = format!("no {}", kind.label());
317                    }
318                    1 => {
319                        if let Some(location) = items.into_iter().next() {
320                            self.jump_to_location(location, context);
321                        }
322                    }
323                    count => {
324                        use strop_picker::{Item, Kind, Payload};
325                        let items = items
326                            .into_iter()
327                            .filter_map(|location| {
328                                let line = location.position.line.get() + 1;
329                                let col = location.position.column.get() + 1;
330                                let text = format!("{}:{}:{}", location.doc.label(), line, col);
331                                let payload = match location.doc.filesystem {
332                                    Filesystem::Local => Payload::Grep {
333                                        path: location.doc.path,
334                                        line,
335                                        col,
336                                        match_len: 1,
337                                        line_text: String::new(),
338                                    },
339                                    Filesystem::Remote(endpoint) => Payload::Remote {
340                                        endpoint,
341                                        path: location.doc.path,
342                                        line,
343                                        col,
344                                    },
345                                    // DC1a wires no container LSP, so a container
346                                    // location cannot arrive; if one ever does, it
347                                    // is dropped with a trace, never aliased to a
348                                    // local path.
349                                    Filesystem::Container(_) => {
350                                        trace::services::rejected(
351                                            "lsp",
352                                            "location in a container namespace (unwired)",
353                                        );
354                                        return None;
355                                    }
356                                };
357                                Some(Item { text, payload })
358                            })
359                            .collect();
360                        let mut glue = super::PickerGlue::diagnostics(strop_picker::Picker::new(
361                            Kind::Locations,
362                            items,
363                            false,
364                        ));
365                        glue.lsp_context = Some(context);
366                        self.set_picker(glue);
367                        self.message = format!("{count} {}", kind.label());
368                    }
369                }
370            }
371        }
372    }
373
374    pub(crate) fn jump_to_location(
375        &mut self,
376        location: strop_lsp::ServerLocation,
377        context: strop_lsp::ReplyContext,
378    ) {
379        if !self.lsp_context_fresh(&context) {
380            return;
381        }
382        let intent = super::io::OpenIntent::LspLocation {
383            context,
384            position: location.position,
385        };
386        match location.doc.filesystem {
387            Filesystem::Local => self.request_open(location.doc.path, intent),
388            Filesystem::Remote(endpoint) => {
389                // The target is a file on the replying server's host:
390                // resolve it through an open document's canonical seed
391                // (`with_path` keeps endpoint + native bytes) — the
392                // analogous local path is never opened or probed.
393                match self.remote_file_for(&endpoint) {
394                    Some(seed) => match seed.with_path(location.doc.path.clone()) {
395                        Ok(file) => self
396                            .request_target(crate::files::FileTarget::Remote(file.into()), intent),
397                        Err(error) => {
398                            trace::services::rejected("lsp", "remote navigation target invalid");
399                            self.message = format!("lsp: remote target invalid: {error}");
400                        }
401                    },
402                    None => {
403                        trace::services::rejected("lsp", "remote navigation endpoint lost");
404                        self.message =
405                            "lsp: the remote workspace for this target was closed".into();
406                    }
407                }
408            }
409            Filesystem::Container(_) => {
410                trace::services::rejected("lsp", "navigation into a container namespace (unwired)");
411                self.message = "lsp: container locations are not navigable yet".into();
412            }
413        }
414    }
415
416    pub(crate) fn finish_lsp_jump(
417        &mut self,
418        target: strop_core::id::DocumentId,
419        position: strop_lsp::ServerPosition,
420        context: strop_lsp::ReplyContext,
421    ) {
422        if !self.lsp_context_fresh(&context) {
423            trace::services::rejected("lsp", "navigation changed while target was loading");
424            return;
425        }
426        let Some(target_doc) = self.docs.get(target) else {
427            return;
428        };
429        let Some(binding) = self.lsp_state.bindings.get(&context.stamp.document) else {
430            return;
431        };
432        let outside = match &target_doc.source {
433            // A remote target is outside the workspace when its remote
434            // path leaves the binding's remote root — never by
435            // comparing against local paths.
436            crate::editor::document::DocumentSource::Remote(file) => {
437                !file.file.path().starts_with(&binding.root)
438            }
439            _ => target_doc
440                .buf
441                .path
442                .as_ref()
443                .is_some_and(|path| !self.cwd.join(path).starts_with(&binding.root)),
444        };
445        let line = position
446            .line
447            .get()
448            .min(target_doc.buf.len_lines().saturating_sub(1));
449        let text = target_doc
450            .buf
451            .text()
452            .byte_slice(target_doc.buf.line_start(line)..target_doc.buf.line_end(line));
453        let col = strop_lsp::to_byte_col_slice(text, position.column, context.encoding).get();
454        let head = target_doc
455            .buf
456            .clamp_boundary(target_doc.buf.line_start(line).saturating_add(col));
457        self.push_jump();
458        self.lsp_state.navigation = None;
459        self.switch_to(target);
460        if outside && !self.buf().readonly {
461            self.buf_mut().readonly = true;
462            self.message = "readonly — outside workspace (:set noro to edit)".into();
463        }
464        self.set_head(head);
465        self.clamp_cursor();
466        self.scroll_to_cursor(self.view_rows());
467        self.lsp_maybe_attach();
468    }
469
470    /// A local picker hit with a live LSP request context: the server
471    /// that produced the list owns the target's filesystem.
472    pub(crate) fn lsp_jump_from_picker(
473        &mut self,
474        path: PathBuf,
475        line: usize,
476        col: usize,
477        context: strop_lsp::ReplyContext,
478    ) {
479        self.jump_to_location(
480            strop_lsp::ServerLocation {
481                doc: ResourceLocation::local(path),
482                position: strop_lsp::ServerPosition {
483                    line: strop_core::id::LineIndex::new(line.saturating_sub(1)),
484                    column: strop_lsp::ServerColumn::new(col.saturating_sub(1)),
485                },
486            },
487            context,
488        );
489    }
490
491    /// A remote picker hit (locations or diagnostics): re-parse the
492    /// endpoint and route through the endpoint's file identity — with
493    /// a live request context through the freshness-checked navigation
494    /// path (server columns), without one as a direct remote open at a
495    /// byte column. The analogous local path is never touched.
496    pub(crate) fn lsp_open_remote_hit(
497        &mut self,
498        endpoint: &strop_workspace::RemoteEndpoint,
499        path: &Path,
500        line: usize,
501        col: usize,
502        context: Option<strop_lsp::ReplyContext>,
503    ) {
504        if let Some(context) = context {
505            self.jump_to_location(
506                strop_lsp::ServerLocation {
507                    doc: ResourceLocation::remote(endpoint.clone(), path.to_owned()),
508                    position: strop_lsp::ServerPosition {
509                        line: strop_core::id::LineIndex::new(line.saturating_sub(1)),
510                        column: strop_lsp::ServerColumn::new(col.saturating_sub(1)),
511                    },
512                },
513                context,
514            );
515        } else if let Some(seed) = self.remote_file_for(endpoint) {
516            // Context-free remote hits (symbol rows) record too —
517            // ctrl-o after the jump returns (0047 §1).
518            self.push_jump();
519            match seed.with_path(path.to_owned()) {
520                Ok(file) => self.request_target(
521                    crate::files::FileTarget::Remote(file.into()),
522                    super::io::OpenIntent::Grep {
523                        line: strop_core::id::LineIndex::new(line.saturating_sub(1)),
524                        column: strop_core::id::ByteColumn::new(col.saturating_sub(1)),
525                    },
526                ),
527                Err(error) => self.message = format!("lsp remote location: {error}"),
528            }
529        } else {
530            self.message = "lsp: the remote workspace for this hit was closed".into();
531        }
532    }
533    pub(crate) fn lsp_locations(&mut self, kind: strop_lsp::LocKind) {
534        self.lsp_request(strop_lsp::RequestKind::Locations(kind));
535    }
536    pub(crate) fn lsp_hover(&mut self) {
537        self.lsp_request(strop_lsp::RequestKind::Hover);
538    }
539    pub(crate) fn lsp_goto_definition(&mut self) {
540        self.lsp_request(strop_lsp::RequestKind::Goto);
541    }
542    pub(crate) fn lsp_switch_source_header(&mut self) {
543        self.lsp_request(strop_lsp::RequestKind::SwitchHeader);
544    }
545    /// `:format` — server formatting through a change plan (0043).
546    pub(crate) fn lsp_format(&mut self) {
547        self.lsp_change_request(strop_lsp::RequestKind::Format, None);
548    }
549    /// `:rename <new>` — workspace rename through a change plan.
550    pub(crate) fn lsp_rename(&mut self, new_name: &str) {
551        self.lsp_change_request(strop_lsp::RequestKind::Rename, Some(new_name.to_string()));
552    }
553    /// `Space s` — the current document's symbols as a picker (0047 §1).
554    pub(crate) fn lsp_document_symbols(&mut self) {
555        self.lsp_request(strop_lsp::RequestKind::DocumentSymbols);
556    }
557    /// `Space a` — code actions at the cursor, offered as a picker.
558    pub(crate) fn lsp_code_actions(&mut self) {
559        self.lsp_change_request(strop_lsp::RequestKind::CodeAction, None);
560    }
561
562    pub(crate) fn jump_diagnostic(&mut self, forward: bool) {
563        let Some(diags) = self
564            .diags_for(self.current())
565            .filter(|diags| !diags.is_empty())
566        else {
567            self.message = "no diagnostics".into();
568            return;
569        };
570        let cur = self.buf().line_of(self.head());
571        let col = self.buf().col_of(self.head());
572        let target = if forward {
573            diags
574                .iter()
575                .find(|d| d.line.get() > cur || (d.line.get() == cur && d.col.get() > col))
576                .or(diags.first())
577        } else {
578            diags
579                .iter()
580                .rev()
581                .find(|d| d.line.get() < cur || (d.line.get() == cur && d.col.get() < col))
582                .or(diags.last())
583        };
584        let Some(d) = target else {
585            return;
586        };
587        let (line, col, msg) = (d.line.get(), d.col.get(), d.message.clone());
588        let start = self
589            .buf()
590            .line_start(line.min(self.buf().len_lines().saturating_sub(1)));
591        self.set_head(self.buf().clamp_boundary(start + col));
592        self.clamp_cursor();
593        self.scroll_to_cursor(self.view_rows());
594        self.message = msg;
595    }
596
597    pub(crate) fn open_diagnostics_picker(&mut self) {
598        use strop_picker::{Item, Kind, Payload};
599        // Deterministic row order across hash seeds (R11).
600        let mut by_doc: Vec<_> = self
601            .diags
602            .keys()
603            .filter_map(|&id| Some((self.lsp_doc_path(id)?, self.diags_for(id)?)))
604            .collect();
605        by_doc.sort_by(|a, b| {
606            (a.0.filesystem.label(), &a.0.path).cmp(&(b.0.filesystem.label(), &b.0.path))
607        });
608        let mut items: Vec<Item> = Vec::new();
609        for (doc, diags) in by_doc {
610            for d in diags {
611                let line = d.line.get() + 1;
612                let col = d.col.get() + 1;
613                match &doc.filesystem {
614                    Filesystem::Local => items.push(Item {
615                        text: format!(
616                            "{}:{} {} {}",
617                            doc.path.display(),
618                            line,
619                            d.severity_char(),
620                            d.message
621                        ),
622                        payload: Payload::Grep {
623                            path: doc.path.clone(),
624                            line,
625                            col,
626                            match_len: 1,
627                            line_text: d.message.clone(),
628                        },
629                    }),
630                    // Remote diagnostics carry their endpoint: the
631                    // preview stays local-clean and acceptance opens
632                    // the remote target (0036).
633                    Filesystem::Remote(endpoint) => items.push(Item {
634                        text: format!(
635                            "{}{}:{} {} {}",
636                            endpoint,
637                            doc.path.display(),
638                            line,
639                            d.severity_char(),
640                            d.message
641                        ),
642                        payload: Payload::Remote {
643                            endpoint: endpoint.clone(),
644                            path: doc.path.clone(),
645                            line,
646                            col,
647                        },
648                    }),
649                    // Unreachable in DC1a (no container bindings); if one
650                    // ever arrives it is dropped with a trace, never
651                    // aliased to a local path.
652                    Filesystem::Container(_) => {
653                        trace::services::rejected(
654                            "lsp",
655                            "diagnostic in a container namespace (unwired)",
656                        );
657                    }
658                }
659            }
660        }
661        if items.is_empty() {
662            self.message = "no diagnostics".into();
663            return;
664        }
665        self.set_picker(super::PickerGlue::diagnostics(strop_picker::Picker::new(
666            Kind::Diagnostics,
667            items,
668            false,
669        )));
670    }
671
672    pub(crate) fn lsp_goto_definition_pub(&mut self) {
673        self.lsp_goto_definition();
674    }
675    pub(crate) fn lsp_switch_source_header_pub(&mut self) {
676        self.lsp_switch_source_header();
677    }
678    pub(crate) fn lsp_hover_pub(&mut self) {
679        self.lsp_hover();
680    }
681    pub fn lsp_code_actions_pub(&mut self) {
682        self.lsp_code_actions();
683    }
684    pub fn lsp_document_symbols_pub(&mut self) {
685        self.lsp_document_symbols();
686    }
687    pub fn lsp_locations_pub(&mut self, kind: strop_lsp::LocKind) {
688        self.lsp_locations(kind);
689    }
690    pub fn jump_diagnostic_pub(&mut self, forward: bool) {
691        self.jump_diagnostic(forward);
692    }
693}
694
695/// The LSP language for a path, from the embedded extension table —
696/// pure, in-memory, safe on every keystroke.
697pub(crate) fn lsp_language(path: &Path) -> Option<&'static str> {
698    let ext = path.extension()?.to_str()?;
699    registry::language_for_extension_name(ext)
700}
701
702/// The didOpen languageId sent to servers.
703pub(crate) fn lang_id(path: &Path) -> &'static str {
704    match path.extension().and_then(|e| e.to_str()) {
705        Some("rs") => "rust",
706        Some("py") | Some("pyi") => "python",
707        Some("go") => "go",
708        Some("js") | Some("jsx") | Some("mjs") | Some("cjs") => "javascript",
709        Some("ts") => "typescript",
710        Some("tsx") => "typescriptreact",
711        Some("json") => "json",
712        Some("sh") | Some("bash") => "shellscript",
713        Some("c") | Some("h") => "c",
714        Some("cpp") | Some("cc") | Some("cxx") | Some("hpp") | Some("hh") => "cpp",
715        _ => "plaintext",
716    }
717}