Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
532 changes: 505 additions & 27 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ members = [
"numbat",
"numbat-exchange-rates",
"numbat-cli",
"numbat-cli-helpers",
"numbat-lsp",
]

exclude = [
Expand Down
7 changes: 7 additions & 0 deletions numbat-cli-helpers/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[package]
name = "numbat-cli-helpers"
version = "0.1.0"
edition = "2024"

[dependencies]
dirs = "6"
32 changes: 32 additions & 0 deletions numbat-cli-helpers/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
use std::path::PathBuf;

pub fn get_modules_paths() -> Vec<PathBuf> {
let mut paths = vec![];

if let Some(modules_path) = std::env::var_os("NUMBAT_MODULES_PATH") {
for path in modules_path.to_string_lossy().split(':') {
paths.push(path.into());
}
}

paths.push(get_config_path().join("modules"));

// We read the value of this environment variable at compile time to
// allow package maintainers to control the system-wide module path
// for Numbat.
if let Some(system_module_path) = option_env!("NUMBAT_SYSTEM_MODULE_PATH") {
if !system_module_path.is_empty() {
paths.push(system_module_path.into());
}
} else if cfg!(unix) {
paths.push("/usr/share/numbat/modules".into());
} else {
paths.push("C:\\Program Files\\numbat\\modules".into());
}
paths
}

pub fn get_config_path() -> PathBuf {
let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
config_dir.join("numbat")
}
1 change: 1 addition & 0 deletions numbat-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ anyhow = "1"
rustyline = { version = "16.0.0", features = ["derive"] }
dirs = "6"
numbat = { version = "1.16.0", path = "../numbat" }
numbat-cli-helpers = { path = "../numbat-cli-helpers" }
colored = "3"
itertools = "0.14"
toml = { version = "0.8.8", features = ["parse"] }
Expand Down
39 changes: 4 additions & 35 deletions numbat-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ struct Cli {
impl Cli {
fn make_fresh_context() -> Context {
let mut fs_importer = FileSystemImporter::default();
for path in Self::get_modules_paths() {
for path in numbat_cli_helpers::get_modules_paths() {
fs_importer.add_path(path);
}

Expand All @@ -154,7 +154,7 @@ impl Cli {
}

fn new(args: Args) -> Result<Self> {
let user_config_path = Self::get_config_path().join("config.toml");
let user_config_path = numbat_cli_helpers::get_config_path().join("config.toml");

let mut config = if args.no_config {
Config::default()
Expand Down Expand Up @@ -212,7 +212,7 @@ impl Cli {
}

if self.config.load_user_init {
let user_init_path = Self::get_config_path().join("init.nbt");
let user_init_path = numbat_cli_helpers::get_config_path().join("init.nbt");

if let Ok(user_init_code) = fs::read_to_string(&user_init_path) {
let result = self.parse_and_evaluate(
Expand Down Expand Up @@ -569,37 +569,6 @@ impl Cli {
.print_diagnostic(error, colored::control::SHOULD_COLORIZE.should_colorize())
}

fn get_config_path() -> PathBuf {
let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
config_dir.join("numbat")
}

fn get_modules_paths() -> Vec<PathBuf> {
let mut paths = vec![];

if let Some(modules_path) = std::env::var_os("NUMBAT_MODULES_PATH") {
for path in modules_path.to_string_lossy().split(':') {
paths.push(path.into());
}
}

paths.push(Self::get_config_path().join("modules"));

// We read the value of this environment variable at compile time to
// allow package maintainers to control the system-wide module path
// for Numbat.
if let Some(system_module_path) = option_env!("NUMBAT_SYSTEM_MODULE_PATH") {
if !system_module_path.is_empty() {
paths.push(system_module_path.into());
}
} else if cfg!(unix) {
paths.push("/usr/share/numbat/modules".into());
} else {
paths.push("C:\\Program Files\\numbat\\modules".into());
}
paths
}

fn get_history_path(&self) -> Result<PathBuf> {
if let Ok(history) = env::var("NUMBAT_HISTORY") {
let history_path = PathBuf::from(history);
Expand All @@ -618,7 +587,7 @@ impl Cli {
}

fn generate_config() -> Result<()> {
let config_folder_path = Cli::get_config_path();
let config_folder_path = numbat_cli_helpers::get_config_path();
let config_file_path = config_folder_path.join("config.toml");

if config_file_path.exists() {
Expand Down
11 changes: 11 additions & 0 deletions numbat-lsp/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[package]
name = "numbat-lsp"
version = "0.1.0"
edition = "2024"

[dependencies]
tokio = { version = "1.48.0", features = ["full"] }
tower-lsp-server = "0.22.1"
numbat = { path = "../numbat" }
numbat-cli-helpers = { path = "../numbat-cli-helpers" }
tempfile = "3.23.0"
224 changes: 224 additions & 0 deletions numbat-lsp/src/backend.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
use std::collections::HashMap;

use numbat::ParseError;
use tokio::sync::RwLock;
use tower_lsp_server::{Client, LanguageServer, jsonrpc, lsp_types::*};

use crate::file_mapper::FileMapping;

#[derive(Debug)]
pub struct Backend {
pub files: RwLock<FileMapping>,

// Client to communicate with the lsp
pub client: Client,
}

impl LanguageServer for Backend {
async fn initialize(
&self,
_params: InitializeParams,
) -> Result<InitializeResult, jsonrpc::Error> {
let init = InitializeResult {
capabilities: ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Kind(
TextDocumentSyncKind::FULL,
)),
hover_provider: Some(HoverProviderCapability::Simple(true)),
completion_provider: None,
signature_help_provider: None,
definition_provider: None,
type_definition_provider: None,
implementation_provider: None,
references_provider: None,
workspace_symbol_provider: None,
code_action_provider: None,
rename_provider: None,
declaration_provider: None,
diagnostic_provider: Some(DiagnosticServerCapabilities::Options(
DiagnosticOptions {
identifier: Some(String::from("numbat")),
inter_file_dependencies: true,
workspace_diagnostics: false,
work_done_progress_options: WorkDoneProgressOptions {
// TODO: What is this?
work_done_progress: Some(false),
},
},
)),
..Default::default()
},

server_info: Some(ServerInfo {
name: "Numbat LSP".to_owned(),
version: None,
}),
};

Ok(init)
}

async fn shutdown(&self) -> Result<(), jsonrpc::Error> {
Ok(())
}

async fn did_open(&self, params: DidOpenTextDocumentParams) {
let uri = params.text_document.uri;
let content = params.text_document.text;

self.client
.log_message(MessageType::LOG, format!("Document {uri:?} opened"))
.await;
self.check_content(uri, content, Some(params.text_document.version))
.await;
}

async fn did_change(&self, change: DidChangeTextDocumentParams) {
let uri = change.text_document.uri;
self.client
.log_message(
MessageType::LOG,
format!(
"Document {uri:?} received {} changes",
change.content_changes.len()
),
)
.await;
let mut content = String::new();
// theoretically we should always have a single change since we asked to
// only receive full changes
for change in change.content_changes {
content = change.text;
}
self.check_content(uri, content, Some(change.text_document.version))
.await;
}

async fn did_close(&self, _params: DidCloseTextDocumentParams) {
// TODO: If the file is not used by anything else we can close it maybe?
}

async fn diagnostic(
&self,
params: DocumentDiagnosticParams,
) -> jsonrpc::Result<DocumentDiagnosticReportResult> {
let uri = params.text_document.uri;
let files = self.files.read().await;
let Some(file) = files.files.get(&uri) else {
let msg = format!("Diagnostic called for unopened file at {uri:?}");
self.client
.log_message(MessageType::WARNING, msg.clone())
.await;
return Err(jsonrpc::Error {
code: jsonrpc::ErrorCode::ServerError(0),
message: msg.into(),
data: None,
});
};

Ok(DocumentDiagnosticReportResult::Report(
DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
related_documents: None,
full_document_diagnostic_report: FullDocumentDiagnosticReport {
result_id: None,
items: file.diags.to_vec(),
},
}),
))
}
}

impl Backend {
async fn check_content(&self, uri: Uri, content: String, version: Option<i32>) {
let module_path = FileMapping::uri_to_module(&uri);
#[allow(clippy::mutable_key_type)]
let mut diags = HashMap::new();
self.files.write().await.load_module(
module_path,
Some(uri.path().as_str()),
Some(content),
&mut diags,
);

for (uri, diags) in diags {
self.client.publish_diagnostics(uri, diags, version).await;
}
}
}

pub fn byte_index_to_pos(bi: usize, content: &str) -> Position {
if bi == 0 {
return Position::new(0, 0);
}

let mut line = 0;
let mut character = 0;
let mut offset = 0;

for c in content.chars() {
if offset >= bi {
return Position { line, character };
}
if c == '\n' {
line += 1;
character = 0;
} else {
character += 1;
}
offset += c.len_utf8();
}

// TODO: That's a failure
Position { line, character }
}

pub fn span_to_range(span: numbat::span::Span, content: &str) -> Range {
Range {
start: byte_index_to_pos(span.start.as_usize(), content),
end: byte_index_to_pos(span.end.as_usize(), content),
}
}

#[allow(dead_code)] // TODO: We'll probably need it later
pub fn pos_to_offset(pos: Position, content: &str) -> usize {
if pos.line == 0 && pos.character == 0 {
return 0;
}

let mut line = 0;
let mut character = 0;
let mut offset = 0;

for c in content.chars() {
if line == pos.line {
if character == pos.character {
return offset;
}
character += 1;
}
if c == '\n' {
line += 1;
}
offset += c.len_utf8();
}

// TODO: That's a failure
offset
}

pub fn parse_error_to_diag(err: &ParseError, content: &str) -> Diagnostic {
Diagnostic {
range: Range {
start: byte_index_to_pos(err.span.start.0 as usize, content),
end: byte_index_to_pos(err.span.end.0 as usize, content),
},
severity: Some(DiagnosticSeverity::ERROR),
code: None,
code_description: None,
source: Some("Parse".to_string()),
message: err.kind.to_string(),
related_information: None,
tags: None,
data: None,
}
}
Loading
Loading