From 8bed715c9c306a126e03e4c8af97319e84169688 Mon Sep 17 00:00:00 2001 From: Alexander Brevig Date: Mon, 4 Mar 2024 00:58:29 +0100 Subject: [PATCH 1/2] feat: ready for v1 and public repo --- .github/workflows/ci.yml | 2 + README.md | 10 ++- src/backend.rs | 157 +++++++++++++++++++++++++++++++-------- src/gh/mod.rs | 2 +- src/gh/wiki.rs | 31 ++++++-- src/lsp.rs | 65 +++++++++++----- 6 files changed, 207 insertions(+), 60 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08fa1b6..d74c2f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,8 @@ jobs: - uses: actions/checkout@v4 - name: Run fmt run: cargo fmt --all --check + - name: Run clippy + run: cargo clippy -- --deny warnings - name: Run tests run: cargo test --verbose - name: Build diff --git a/README.md b/README.md index b803858..c65541b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # github-lsp +[![CI](https://github.com/AlexanderBrevig/github-lsp/actions/workflows/ci.yml/badge.svg)](https://github.com/AlexanderBrevig/github-lsp/actions/workflows/ci.yml) + `github-lsp` is an implementation of the [Language Server Protocol](https://microsoft.github.io/language-server-protocol/) for working with [GitHub Markdown](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) files. This is a tool for getting link suggestions while writing READMEs and GitHub Wiki pages locally. @@ -7,10 +9,10 @@ This is a tool for getting link suggestions while writing READMEs and GitHub Wik Use this LSP in conjunction with some other Markdown LSP if you want gotoDefinition et.al. This LSP only focuses on adding autocomplete to - [x] `#` Issues and PRs -- [ ] `[` Wiki Pages -- [ ] `:` Organizations / Owners -- [ ] `/` Repositories -- [ ] `@` Users +- [x] `[` Wiki Pages +- [x] `:` Organizations / Owners +- [x] `/` Repositories +- [x] `@` Users [Issues](https://github.com/AlexanderBrevig/github-lsp/issues) and [PRs](https://github.com/AlexanderBrevig/github-lsp/pulls) are very welcome! diff --git a/src/backend.rs b/src/backend.rs index d3ff18e..9a855f5 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -9,7 +9,8 @@ use tower_lsp::lsp_types::{ }; use tower_lsp::{lsp_types::Position, Client}; -use crate::gh::{GetDetail, GetEdit, GetLabel}; +use crate::gh::wiki::WikiArticle; +use crate::gh::{self, GetDetail, GetEdit, GetLabel}; #[derive(Debug)] pub struct Backend { @@ -18,12 +19,23 @@ pub struct Backend { pub(crate) repository_map: DashMap, pub(crate) issue_map: DashMap, pub(crate) member_map: DashMap, + pub(crate) wiki_map: DashMap, octocrab: Octocrab, owner: String, repo: String, } impl Backend { + const PER_PAGE: u8 = 100; + + pub(crate) async fn initialize(&self) { + self.initialize_issues().await; + self.initialize_members().await; + self.initialize_repos_as("owner").await; + self.initialize_repos_as("organization_member").await; + self.initialize_wiki().await; + } + pub(crate) async fn search_issue_and_pr( &self, position: Position, @@ -36,18 +48,6 @@ impl Backend { ) .await; //TODO: refresh issues - // let filter = format!("{} repo:{}/{}", needle, self.owner, self.repo); - // let page = self - // .octocrab - // .search() - // .issues_and_pull_requests(&filter) - // .sort("status") - // .per_page(100) - // .send() - // .await - // .map_err(|_| { - // tower_lsp::jsonrpc::Error::new(tower_lsp::jsonrpc::ErrorCode::MethodNotFound) - // })?; let completion_items = self .issue_map .iter() @@ -102,11 +102,35 @@ impl Backend { Ok(completion_items) } - pub(crate) async fn search_wiki(&self, needle: &str) -> Result> { + pub(crate) async fn search_wiki( + &self, + position: Position, + needle: &str, + ) -> Result> { self.client .log_message(MessageType::INFO, format!("search_wiki: {}", needle)) .await; - Ok(vec![]) + let completion_items = self + .wiki_map + .iter() + .filter(|member| member.title.starts_with(needle)) //TODO: smarter fuzzy match + .map(|member| CompletionItem { + label: member.title.to_owned(), + detail: None, + text_edit: Some(CompletionTextEdit::Edit(TextEdit { + range: Range { + start: Position { + line: position.line, + character: position.character - needle.len() as u32 - 1, + }, + end: position, + }, + new_text: member.get_edit(), + })), + ..CompletionItem::default() + }) + .collect::>(); + Ok(completion_items) } pub(crate) async fn search_repo( @@ -186,38 +210,72 @@ impl Backend { self.document_map .insert(params.uri.to_string(), rope.clone()); } - pub(crate) async fn initialize(&self) { - self.initialize_repos().await; - self.initialize_issues().await; - self.initialize_members().await; - } - async fn initialize_repos(&self) { - let Ok(repos) = self + async fn initialize_repos_as(&self, affiliation: &str) { + self.client + .log_message( + MessageType::INFO, + format!("initialize_repos_as: {}", affiliation), + ) + .await; + let mut page: u8 = 0; + let mut repos: Vec = vec![]; + while let Ok(mut page_repos) = self .octocrab .current() .list_repos_for_authenticated_user() - .affiliation("organization_member") + .affiliation(affiliation) .sort("updated") - .per_page(100) + .per_page(Backend::PER_PAGE) + .page(page) .send() .await - else { + { + if page_repos.items.is_empty() { + break; + } + repos.append(page_repos.items.as_mut()); + page += 1; + } + if repos.is_empty() { + self.client + .log_message( + MessageType::WARNING, + format!("No repos found with affiliation {}", affiliation), + ) + .await; return; }; repos.into_iter().for_each(|repo| { - self.repository_map.insert(repo.name.to_owned(), repo); + let _ = self.repository_map.insert(repo.name.to_owned(), repo); }); } async fn initialize_issues(&self) { - let Ok(issues) = self + self.client + .log_message(MessageType::INFO, "initialize_issues") + .await; + let mut page: u8 = 0; + let mut issues: Vec = vec![]; + while let Ok(mut page_issues) = self .octocrab .issues(&self.owner, &self.repo) .list() + .per_page(Backend::PER_PAGE) + .page(page) .send() .await - else { + { + if page_issues.items.is_empty() { + break; + } + issues.append(page_issues.items.as_mut()); + page += 1; + } + if issues.is_empty() { + self.client + .log_message(MessageType::WARNING, "No issues found") + .await; return; }; issues.into_iter().for_each(|issue| { @@ -225,15 +283,49 @@ impl Backend { }); } + async fn initialize_wiki(&self) { + self.client + .log_message(MessageType::INFO, "initialize_wiki") + .await; + let wikis = gh::wiki::find_wiki_articles(&self.owner, &self.repo).await; + match wikis { + Ok(articles) => articles.into_iter().for_each(|article| { + self.wiki_map.insert(article.title.to_owned(), article); + }), + Err(_) => { + self.client + .log_message(MessageType::WARNING, "No wiki found") + .await; + } + } + //TODO: load local .md files and make relative links? + } + async fn initialize_members(&self) { - let Ok(members) = self + self.client + .log_message(MessageType::INFO, "initialize_members") + .await; + let mut page: u8 = 0; + let mut members: Vec = vec![]; + while let Ok(mut page_members) = self .octocrab - .orgs("entur") + .orgs(self.owner.to_owned()) .list_members() - .page(0_u32) + .per_page(Backend::PER_PAGE) + .page(page) .send() .await - else { + { + if page_members.items.is_empty() { + break; + } + members.append(page_members.items.as_mut()); + page += 1; + } + if members.is_empty() { + self.client + .log_message(MessageType::WARNING, "No members found") + .await; return; }; members.into_iter().for_each(|member| { @@ -251,6 +343,7 @@ impl Backend { repository_map: DashMap::new(), issue_map: DashMap::new(), member_map: DashMap::new(), + wiki_map: DashMap::new(), } } } diff --git a/src/gh/mod.rs b/src/gh/mod.rs index 1b2b296..27e4547 100644 --- a/src/gh/mod.rs +++ b/src/gh/mod.rs @@ -1,7 +1,7 @@ mod author; mod issue; mod repo; -mod wiki; +pub(crate) mod wiki; use std::fmt; diff --git a/src/gh/wiki.rs b/src/gh/wiki.rs index e9cbcb6..181262c 100644 --- a/src/gh/wiki.rs +++ b/src/gh/wiki.rs @@ -1,17 +1,32 @@ use reqwest::Client; use select::{document::Document, predicate::Name}; +use super::GetEdit; + +#[derive(Debug)] pub(crate) struct WikiArticle { - title: String, - uri: String, + pub title: String, + pub uri: String, +} + +impl GetEdit for WikiArticle { + fn get_edit(&self) -> String { + let title = self.title.to_owned(); + let uri = self.uri.to_owned(); + format!("[{title}]({uri})") + } } -pub(crate) async fn find_wiki_articles( - client: &Client, - url: &str, + +pub async fn find_wiki_articles( + owner: &str, + repo: &str, ) -> Result, reqwest::Error> { + let client = Client::new(); + //TODO: find a way to support private wikis? + let url = format!("https://github.com/{owner}/{repo}/wiki"); let res = client.get(url).send().await?; let body = res.text().await?; - let ret: Vec = Document::from(body.as_str()) + let mut ret: Vec = Document::from(body.as_str()) .find(Name("a")) .filter(|a| a.attr("href").is_some()) .filter(|a| { @@ -26,6 +41,10 @@ pub(crate) async fn find_wiki_articles( uri: link.attr("href").unwrap().to_string(), }) .collect(); + ret.push(WikiArticle { + title: "Home".into(), + uri: format!("/{owner}/{repo}/wiki"), + }); Ok(ret) } diff --git a/src/lsp.rs b/src/lsp.rs index 8eda460..70a7348 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -11,13 +11,20 @@ impl LanguageServer for Backend { Ok(InitializeResult { server_info: None, capabilities: ServerCapabilities { - //TODO: this is probably much better for performance + text_document_sync: Some(TextDocumentSyncCapability::Options( + TextDocumentSyncOptions { + open_close: Some(true), + change: Some(TextDocumentSyncKind::INCREMENTAL), + will_save: None, + will_save_wait_until: None, + save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions { + include_text: Some(true), + })), + }, + )), // text_document_sync: Some(TextDocumentSyncCapability::Kind( - // TextDocumentSyncKind::INCREMENTAL, + // TextDocumentSyncKind::FULL, // )), - text_document_sync: Some(TextDocumentSyncCapability::Kind( - TextDocumentSyncKind::FULL, - )), completion_provider: Some(CompletionOptions { resolve_provider: Some(false), trigger_characters: Some(vec![ @@ -49,11 +56,10 @@ impl LanguageServer for Backend { } async fn initialized(&self, _: InitializedParams) { + self.initialize().await; self.client .log_message(MessageType::INFO, "initialized!") .await; - - self.initialize().await; } async fn shutdown(&self) -> Result<()> { @@ -105,23 +111,48 @@ impl LanguageServer for Backend { .await } - async fn did_change(&self, mut params: DidChangeTextDocumentParams) { + async fn did_change(&self, params: DidChangeTextDocumentParams) { self.client .log_message(MessageType::INFO, "file changed!") .await; - self.on_change(TextDocumentItem { - uri: params.text_document.uri, - text: std::mem::take(&mut params.content_changes[0].text), - version: params.text_document.version, - language_id: "md".into(), //TODO: is this the way? - }) - .await + let mut text = self + .document_map + .get_mut(¶ms.text_document.uri.to_string()) + .expect("Did change docs must be opened"); + params.content_changes.iter().for_each(|change| { + if let Some(range) = change.range { + let start = + text.line_to_char(range.start.line as usize) + range.start.character as usize; + let end = text.line_to_char(range.end.line as usize) + range.end.character as usize; + if start < end { + text.remove(start..end); + } + text.insert(start, &change.text); + // eprintln!("{}", *text); + } + }); + // self.on_change(TextDocumentItem { + // uri: params.text_document.uri, + // text: text, + // version: params.text_document.version, + // language_id: "md".into(), //TODO: is this the way? + // }) + // .await } - async fn did_save(&self, _: DidSaveTextDocumentParams) { + async fn did_save(&self, params: DidSaveTextDocumentParams) { self.client .log_message(MessageType::INFO, "file saved!") .await; + if let Some(text) = params.text { + self.on_change(TextDocumentItem { + uri: params.text_document.uri, + text, + version: 0, //TODO: not sure if we should forward version + language_id: "md".into(), //TODO: is this the way? + }) + .await + } } async fn did_close(&self, _: DidCloseTextDocumentParams) { @@ -154,7 +185,7 @@ impl LanguageServer for Backend { let completions = match parts.0 { "#" => self.search_issue_and_pr(position, parts.1).await, "@" => self.search_user(position, parts.1).await, - "[" => self.search_wiki(parts.1).await, + "[" => self.search_wiki(position, parts.1).await, "/" => self.search_repo(position, parts.1).await, ":" => self.search_owner(position, parts.1).await, _ => Ok(vec![]), From de8caa0374990a6982226aa05eb99015a81b26b5 Mon Sep 17 00:00:00 2001 From: Alexander Brevig Date: Mon, 4 Mar 2024 01:01:11 +0100 Subject: [PATCH 2/2] docs: update README before going public --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c65541b..c93a380 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,10 @@ This is a tool for getting link suggestions while writing READMEs and GitHub Wik Use this LSP in conjunction with some other Markdown LSP if you want gotoDefinition et.al. This LSP only focuses on adding autocomplete to - [x] `#` Issues and PRs -- [x] `[` Wiki Pages +- [x] `[` Public Wiki Pages - [x] `:` Organizations / Owners -- [x] `/` Repositories -- [x] `@` Users +- [x] `/` Repositories (yours and the orgs you are part of, no global search yet) +- [x] `@` Organization Members [Issues](https://github.com/AlexanderBrevig/github-lsp/issues) and [PRs](https://github.com/AlexanderBrevig/github-lsp/pulls) are very welcome!