diff --git a/Cargo.lock b/Cargo.lock index 0113c418..d591a0e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -353,6 +353,27 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" +[[package]] +name = "csv" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdc4883a9c96732e4733212c01447ebd805833b7275a73ca3ee080fd77afdaf" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70" +dependencies = [ + "memchr", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -692,6 +713,7 @@ version = "0.16.0" dependencies = [ "anyhow", "clap", + "csv", "dirs 6.0.0", "futures", "harper-comments", @@ -709,6 +731,7 @@ dependencies = [ "tower-lsp", "tracing", "tracing-subscriber", + "uuid", ] [[package]] @@ -2206,6 +2229,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744018581f9a3454a9e15beb8a33b017183f1e7c0cd170232a2d1453b23a51c4" +dependencies = [ + "getrandom", + "serde", +] + [[package]] name = "valuable" version = "0.1.0" diff --git a/harper-core/src/linting/lint.rs b/harper-core/src/linting/lint.rs index eeb0f910..9e60dd6f 100644 --- a/harper-core/src/linting/lint.rs +++ b/harper-core/src/linting/lint.rs @@ -28,7 +28,7 @@ impl Default for Lint { } } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, Is, Default)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Is, Default, PartialEq, Eq, Hash)] pub enum LintKind { Spelling, Capitalization, @@ -42,6 +42,23 @@ pub enum LintKind { Miscellaneous, } +impl LintKind { + pub fn new_from_str(s: &str) -> Option { + Some(match s { + "Spelling" => LintKind::Spelling, + "Capitalization" => LintKind::Capitalization, + "Formatting" => LintKind::Formatting, + "Repetition" => LintKind::Repetition, + "Readability" => LintKind::Readability, + "Miscellaneous" => LintKind::Miscellaneous, + "Enhancement" => LintKind::Enhancement, + "Word Choice" => LintKind::WordChoice, + "Style" => LintKind::Style, + _ => return None, + }) + } +} + impl Display for LintKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let s = match self { diff --git a/harper-ls/Cargo.toml b/harper-ls/Cargo.toml index d04d375a..97841bd1 100644 --- a/harper-ls/Cargo.toml +++ b/harper-ls/Cargo.toml @@ -27,6 +27,8 @@ resolve-path = "0.1.0" open = "5.3.0" futures = "0.3.31" serde = { version = "1.0.214", features = ["derive"] } +uuid = { version = "1.12.0", features = ["serde", "v4"] } +csv = "1.3.1" [features] default = [] diff --git a/harper-ls/src/backend.rs b/harper-ls/src/backend.rs index a98b8351..45fc839a 100644 --- a/harper-ls/src/backend.rs +++ b/harper-ls/src/backend.rs @@ -1,10 +1,13 @@ use std::collections::HashMap; +use std::fs::{File, OpenOptions}; +use std::io::{BufWriter, Write}; use std::path::{Component, PathBuf}; use std::sync::Arc; use anyhow::{anyhow, Context, Result}; +use futures::future::join; use harper_comments::CommentParser; -use harper_core::linting::{LintGroup, Linter}; +use harper_core::linting::{LintGroup, LintKind, Linter}; use harper_core::parsers::{CollapseIdentifiers, IsolateEnglish, Markdown, Parser, PlainEnglish}; use harper_core::{ Dictionary, Document, FstDictionary, FullDictionary, MergedDictionary, Token, TokenKind, @@ -37,10 +40,12 @@ use crate::dictionary_io::{load_dict, save_dict}; use crate::document_state::DocumentState; use crate::git_commit_parser::GitCommitParser; use crate::pos_conv::range_to_span; +use crate::stats::{LintRecord, Stats}; pub struct Backend { client: Client, config: RwLock, + stats: RwLock, doc_state: Mutex>, } @@ -48,6 +53,7 @@ impl Backend { pub fn new(client: Client, config: Config) -> Self { Self { client, + stats: RwLock::new(Stats::new()), config: RwLock::new(config), doc_state: Mutex::new(HashMap::new()), } @@ -121,6 +127,26 @@ impl Backend { .map_err(|err| anyhow!("Unable to save the dictionary to file: {err}")) } + async fn save_stats(&self) -> Result<()> { + let (config, stats) = join(self.config.read(), self.stats.read()).await; + + if let Some(parent) = config.stats_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + let mut writer = BufWriter::new( + OpenOptions::new() + .read(true) + .append(true) + .create(true) + .open(&config.stats_path)?, + ); + stats.write_csv(&mut writer)?; + writer.flush()?; + + Ok(()) + } + async fn generate_global_dictionary(&self) -> Result { let mut dict = MergedDictionary::new(); dict.add_dictionary(FstDictionary::curated()); @@ -390,6 +416,7 @@ impl LanguageServer for Backend { code_action_provider: Some(CodeActionProviderCapability::Simple(true)), execute_command_provider: Some(ExecuteCommandOptions { commands: vec![ + "HarperRecordLint".to_owned(), "HarperAddToUserDict".to_owned(), "HarperAddToFileDict".to_owned(), "HarperOpen".to_owned(), @@ -523,6 +550,20 @@ impl LanguageServer for Backend { info!("Received command: \"{}\"", params.command.as_str()); match params.command.as_str() { + "HarperRecordLint" => { + let Some(kind) = LintKind::new_from_str(&first) else { + error!("Unable to deserialize LintKind."); + return Ok(None); + }; + + let Ok(record) = LintRecord::record_now(kind) else { + error!("System time error"); + return Ok(None); + }; + + let mut stats = self.stats.write().await; + stats.lint_applied(record); + } "HarperAddToUserDict" => { let word = &first.chars().collect::>(); @@ -646,6 +687,10 @@ impl LanguageServer for Backend { .await; } + if self.save_stats().await.is_err() { + error!("Unable to save stats.") + } + Ok(()) } } diff --git a/harper-ls/src/config.rs b/harper-ls/src/config.rs index 79c9601c..fc296e11 100644 --- a/harper-ls/src/config.rs +++ b/harper-ls/src/config.rs @@ -65,6 +65,7 @@ impl CodeActionConfig { pub struct Config { pub user_dict_path: PathBuf, pub file_dict_path: PathBuf, + pub stats_path: PathBuf, pub lint_config: LintGroupConfig, pub diagnostic_severity: DiagnosticSeverity, pub code_action_config: CodeActionConfig, @@ -100,6 +101,14 @@ impl Config { } } + if let Some(v) = value.get("statsPath") { + if let Value::String(path) = v { + base.file_dict_path = path.try_resolve()?.to_path_buf(); + } else { + bail!("fileDict path must be a string."); + } + } + if let Some(v) = value.get("linters") { base.lint_config = serde_json::from_value(v.clone())?; } @@ -135,6 +144,7 @@ impl Default for Config { file_dict_path: data_local_dir() .unwrap() .join("harper-ls/file_dictionaries/"), + stats_path: data_local_dir().unwrap().join("harper-ls/stats.txt"), lint_config: LintGroupConfig::default(), diagnostic_severity: DiagnosticSeverity::Hint, code_action_config: CodeActionConfig::default(), diff --git a/harper-ls/src/diagnostics.rs b/harper-ls/src/diagnostics.rs index e30710d4..4256a01d 100644 --- a/harper-ls/src/diagnostics.rs +++ b/harper-ls/src/diagnostics.rs @@ -1,7 +1,9 @@ use std::collections::HashMap; +use std::str::FromStr; use harper_core::linting::{Lint, Suggestion}; use harper_core::CharStringExt; +use serde_json::Value; use tower_lsp::lsp_types::{ CodeAction, CodeActionKind, CodeActionOrCommand, Command, Diagnostic, TextEdit, Url, WorkspaceEdit, @@ -60,7 +62,11 @@ pub fn lint_to_code_actions<'a>( document_changes: None, change_annotations: None, }), - command: None, + command: Some(Command { + title: "Record lint statistic".to_owned(), + command: "HarperRecordLint".to_owned(), + arguments: Some(vec![Value::String(lint.lint_kind.to_string())]), + }), is_preferred: None, disabled: None, data: None, diff --git a/harper-ls/src/main.rs b/harper-ls/src/main.rs index 1f20b1e5..ad725e79 100644 --- a/harper-ls/src/main.rs +++ b/harper-ls/src/main.rs @@ -11,6 +11,7 @@ mod dictionary_io; mod document_state; mod git_commit_parser; mod pos_conv; +mod stats; use backend::Backend; use clap::Parser; diff --git a/harper-ls/src/stats/lint_record.rs b/harper-ls/src/stats/lint_record.rs new file mode 100644 index 00000000..98c8b89a --- /dev/null +++ b/harper-ls/src/stats/lint_record.rs @@ -0,0 +1,24 @@ +use std::time::{SystemTime, SystemTimeError, UNIX_EPOCH}; + +use harper_core::linting::LintKind; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Deserialize, Serialize)] +pub struct LintRecord { + pub kind: LintKind, + /// Recorded as seconds from the Unix Epoch + pub when: u64, + pub uuid: Uuid, +} + +impl LintRecord { + /// Record a new instance at the current system time. + pub fn record_now(kind: LintKind) -> Result { + Ok(Self { + kind, + when: SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(), + uuid: Uuid::new_v4(), + }) + } +} diff --git a/harper-ls/src/stats/lint_summary.rs b/harper-ls/src/stats/lint_summary.rs new file mode 100644 index 00000000..2df928fd --- /dev/null +++ b/harper-ls/src/stats/lint_summary.rs @@ -0,0 +1,28 @@ +use std::collections::HashMap; + +use harper_core::linting::LintKind; + +pub struct LintSummary { + counts: HashMap, +} + +impl LintSummary { + pub fn new() -> Self { + Self { + counts: HashMap::new(), + } + } + + /// Increment the count for a particular lint kind. + pub fn inc(&mut self, kind: LintKind) { + self.counts + .entry(kind) + .and_modify(|counter| *counter += 1) + .or_insert(1); + } + + /// Get the count for a particular lint kind. + pub fn get(&self, kind: LintKind) -> usize { + self.counts.get(&kind).copied().unwrap_or(0) + } +} diff --git a/harper-ls/src/stats/mod.rs b/harper-ls/src/stats/mod.rs new file mode 100644 index 00000000..900dc59b --- /dev/null +++ b/harper-ls/src/stats/mod.rs @@ -0,0 +1,52 @@ +mod lint_record; +mod lint_summary; + +use std::io::Write; + +pub use lint_record::LintRecord; +pub use lint_summary::LintSummary; +use tokio::io; + +pub struct Stats { + /// A record of the lints the user has applied. + lints_applied: Vec, +} + +impl Stats { + pub fn new() -> Self { + Self { + lints_applied: Vec::new(), + } + } + + /// Count the number of each kind of lint applied. + pub fn summarize_lints_applied(&self) -> LintSummary { + let mut summary = LintSummary::new(); + + for lint in &self.lints_applied { + summary.inc(lint.kind); + } + + summary + } + + pub fn lint_applied(&mut self, record: LintRecord) { + self.lints_applied.push(record); + } + + pub fn write_csv(&self, w: &mut impl Write) -> io::Result<()> { + let mut writer = csv::WriterBuilder::new().has_headers(false).from_writer(w); + + for record in &self.lints_applied { + writer.serialize(record)?; + } + + Ok(()) + } +} + +impl Default for Stats { + fn default() -> Self { + Self::new() + } +}