From 6621e4c859a3546070932318b3b31066c811e786 Mon Sep 17 00:00:00 2001 From: Nick Young Date: Wed, 2 Oct 2019 16:56:49 +1000 Subject: [PATCH] feat: Add dotnet module (#416) Adds a .NET module, which preferentially parses local/git files to get the .NET version. --- .github/workflows/continuous-integration.yml | 11 +- docs/config/README.md | 44 ++- starship/src/configs/dotnet.rs | 31 ++ starship/src/configs/mod.rs | 13 +- starship/src/module.rs | 3 + starship/src/modules/directory.rs | 2 +- starship/src/modules/dotnet.rs | 314 +++++++++++++++++++ starship/src/modules/mod.rs | 37 ++- tests/Dockerfile | 13 + tests/testsuite/dotnet.rs | 146 +++++++++ tests/testsuite/main.rs | 1 + 11 files changed, 587 insertions(+), 28 deletions(-) create mode 100644 starship/src/configs/dotnet.rs create mode 100644 starship/src/modules/dotnet.rs create mode 100644 tests/testsuite/dotnet.rs diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 37814905..f202333a 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -70,7 +70,12 @@ jobs: - uses: actions/setup-python@master with: python-version: "3.6.9" - + + # Install dotnet at a fixed version + - uses: actions/setup-dotnet@master + with: + dotnet-version: "2.2.402" + # Run the ignored tests that expect the above setup - uses: actions/checkout@master - name: Run all tests @@ -88,6 +93,8 @@ jobs: - name: Fix file permissions run: chmod -R a+w . - name: Build the Docker image - run: docker build -f tests/Dockerfile --tag starshipcommand/starship-test --cache-from starshipcommand/starship-test . + run: + docker build -f tests/Dockerfile --tag starshipcommand/starship-test --cache-from + starshipcommand/starship-test . - name: Run tests in Docker run: docker run --rm -v $(pwd):/src/starship starshipcommand/starship-test diff --git a/docs/config/README.md b/docs/config/README.md index 5e2cf8de..2e087ac6 100644 --- a/docs/config/README.md +++ b/docs/config/README.md @@ -91,12 +91,13 @@ prompt_order = [ "git_state", "git_status", "package", - "nodejs", - "ruby", - "rust", - "python", + "dotnet", "golang", "java", + "nodejs", + "python", + "ruby", + "rust", "nix_shell", "memory_usage", "aws", @@ -317,6 +318,41 @@ it would have been `nixpkgs/pkgs`. truncation_length = 8 ``` +## Dotnet + +The `dotnet` module shows the relevant version of the .NET Core SDK for the current directory. If +the SDK has been pinned in the current directory, the pinned version is shown. Otherwise the module +shows the latest installed version of the SDK. + +This module will only be shown in your prompt when one of the following files are present in the +current directory: `global.json`, `project.json`, `*.sln`, `*.csproj`, `*.fsproj`, `*.xproj`. You'll +also need the .NET Core command-line tools installed in order to use it correctly. + +Internally, this module uses its own mechanism for version detection. Typically it is twice as fast +as running `dotnet --version`, but it may show an incorrect version if your .NET project has an +unusual directory layout. If accuracy is more important than speed, you can disable the mechanism by +setting `heuristic = false` in the module options. + +### Options + +| Variable | Default | Description | +| ----------- | ------------- | -------------------------------------------------------- | +| `symbol` | `"•NET "` | The symbol used before displaying the version of dotnet. | +| `style` | `"bold blue"` | The style for the module. | +| `heuristic` | `true` | Use faster version detection to keep starship snappy. | +| `disabled` | `false` | Disables the `dotnet` module. | + +### Example + +```toml +# ~/.config/starship.toml + +[dotnet] +symbol = "🥅 " +style = "green" +heuristic = false +``` + ## Environment Variable The `env_var` module displays the current value of a selected environment variable. diff --git a/starship/src/configs/dotnet.rs b/starship/src/configs/dotnet.rs new file mode 100644 index 00000000..084798c6 --- /dev/null +++ b/starship/src/configs/dotnet.rs @@ -0,0 +1,31 @@ +use crate::config::{ModuleConfig, RootModuleConfig, SegmentConfig}; + +use ansi_term::{Color, Style}; +use starship_module_config_derive::ModuleConfig; + +#[derive(Clone, ModuleConfig)] +pub struct DotnetConfig<'a> { + pub symbol: SegmentConfig<'a>, + pub version: SegmentConfig<'a>, + pub style: Style, + pub heuristic: bool, + pub disabled: bool, +} + +impl<'a> RootModuleConfig<'a> for DotnetConfig<'a> { + fn new() -> Self { + DotnetConfig { + symbol: SegmentConfig { + value: "•NET ", + style: None, + }, + version: SegmentConfig { + value: "", + style: None, + }, + style: Color::Blue.bold(), + heuristic: true, + disabled: false, + } + } +} diff --git a/starship/src/configs/mod.rs b/starship/src/configs/mod.rs index bac18a3f..dcf8bb00 100644 --- a/starship/src/configs/mod.rs +++ b/starship/src/configs/mod.rs @@ -1,4 +1,5 @@ pub mod battery; +pub mod dotnet; pub mod rust; use crate::config::{ModuleConfig, RootModuleConfig}; @@ -26,12 +27,16 @@ impl<'a> RootModuleConfig<'a> for StarshipRootConfig<'a> { "git_state", "git_status", "package", - "nodejs", - "ruby", - "rust", - "python", + // ↓ Toolchain version modules ↓ + // (Let's keep these sorted alphabetically) + "dotnet", "golang", "java", + "nodejs", + "python", + "ruby", + "rust", + // ↑ Toolchain version modules ↑ "nix_shell", "memory_usage", "aws", diff --git a/starship/src/module.rs b/starship/src/module.rs index 8ebb89fd..d14814eb 100644 --- a/starship/src/module.rs +++ b/starship/src/module.rs @@ -5,6 +5,8 @@ use ansi_term::{ANSIString, ANSIStrings}; use std::fmt; // List of all modules +// Keep these ordered alphabetically. +// Default ordering is handled in configs/mod.rs pub const ALL_MODULES: &[&str] = &[ "aws", #[cfg(feature = "battery")] @@ -12,6 +14,7 @@ pub const ALL_MODULES: &[&str] = &[ "character", "cmd_duration", "directory", + "dotnet", "env_var", "git_branch", "git_state", diff --git a/starship/src/modules/directory.rs b/starship/src/modules/directory.rs index 4bab47c7..fd9e74fd 100644 --- a/starship/src/modules/directory.rs +++ b/starship/src/modules/directory.rs @@ -122,7 +122,7 @@ fn contract_path(full_path: &Path, top_level_path: &Path, top_level_replacement: /// On non-Windows OS, does nothing #[cfg(target_os = "windows")] fn replace_c_dir(path: String) -> String { - return path.replace("C:/", "/c"); + path.replace("C:/", "/c") } /// Replaces "C://" with "/c/" within a Windows path diff --git a/starship/src/modules/dotnet.rs b/starship/src/modules/dotnet.rs new file mode 100644 index 00000000..d63256e5 --- /dev/null +++ b/starship/src/modules/dotnet.rs @@ -0,0 +1,314 @@ +use std::ffi::OsStr; +use std::iter::Iterator; +use std::ops::Deref; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::str; + +use super::{Context, Module}; +use crate::config::RootModuleConfig; +use crate::configs::dotnet::DotnetConfig; + +type JValue = serde_json::Value; + +const GLOBAL_JSON_FILE: &str = "global.json"; +const PROJECT_JSON_FILE: &str = "project.json"; + +/// A module which shows the latest (or pinned) version of the dotnet SDK +/// +/// Will display if any of the following files are present in +/// the current directory: +/// global.json, project.json, *.sln, *.csproj, *.fsproj, *.xproj +pub fn module<'a>(context: &'a Context) -> Option> { + let dotnet_files = get_local_dotnet_files(context).ok()?; + if dotnet_files.is_empty() { + return None; + } + + let mut module = context.new_module("dotnet"); + let config = DotnetConfig::try_load(module.config); + + // Internally, this module uses its own mechanism for version detection. + // Typically it is twice as fast as running `dotnet --version`. + let enable_heuristic = config.heuristic; + let version = if enable_heuristic { + let repo_root = context + .get_repo() + .ok() + .and_then(|r| r.root.as_ref().map(PathBuf::as_path)); + estimate_dotnet_version(&dotnet_files, &context.current_dir, repo_root)? + } else { + get_version_from_cli()? + }; + + module.set_style(config.style); + module.create_segment("symbol", &config.symbol); + module.create_segment("version", &config.version.with_value(&version.0)); + + Some(module) +} + +fn estimate_dotnet_version<'a>( + files: &[DotNetFile<'a>], + current_dir: &Path, + repo_root: Option<&Path>, +) -> Option { + let get_file_of_type = |t: FileType| files.iter().find(|f| f.file_type == t); + + // It's important to check for a global.json or a solution file first, + // but otherwise we can take any relevant file. We'll take whichever is first. + let relevant_file = get_file_of_type(FileType::GlobalJson) + .or_else(|| get_file_of_type(FileType::SolutionFile)) + .or_else(|| files.iter().next())?; + + match relevant_file.file_type { + FileType::GlobalJson => { + get_pinned_sdk_version_from_file(relevant_file.path).or_else(get_latest_sdk_from_cli) + } + FileType::SolutionFile => { + // With this heuristic, we'll assume that a "global.json" won't + // be found in any directory above the solution file. + get_latest_sdk_from_cli() + } + _ => { + // If we see a dotnet project, we'll check a small number of neighboring + // directories to see if we can find a global.json. Otherwise, assume the + // latest SDK is in use. + try_find_nearby_global_json(current_dir, repo_root).or_else(get_latest_sdk_from_cli) + } + } +} + +/// Looks for a `global.json` which may exist in one of the parent directories of the current path. +/// If there is one present, and it contains valid version pinning information, then return that version. +/// +/// The following places are scanned: +/// - The parent of the current directory +/// (Unless there is a git repository, and the parent is above the root of that repository) +/// - The root of the git repository +/// (If there is one) +fn try_find_nearby_global_json(current_dir: &Path, repo_root: Option<&Path>) -> Option { + let current_dir_is_repo_root = repo_root.map(|r| r == current_dir).unwrap_or(false); + let parent_dir = if current_dir_is_repo_root { + // Don't scan the parent directory if it's above the root of a git repository + None + } else { + current_dir.parent() + }; + + // Check the parent directory, or otherwise the repository root, for a global.json + let mut check_dirs = parent_dir + .iter() + .chain(repo_root.iter()) + .copied() // Copies the reference, not the Path itself + .collect::>(); + + // The parent directory and repository root may be the same directory, + // so avoid checking it twice. + check_dirs.dedup(); + + check_dirs + .iter() + // repo_root may be the same as the current directory. We don't need to scan it again. + .filter(|&&d| d != current_dir) + .filter_map(|d| check_directory_for_global_json(d)) + // This will lazily evaluate the first directory with a global.json + .next() +} + +fn check_directory_for_global_json(path: &Path) -> Option { + let global_json_path = path.join(GLOBAL_JSON_FILE); + log::debug!( + "Checking if global.json exists at: {}", + &global_json_path.display() + ); + if global_json_path.exists() { + get_pinned_sdk_version_from_file(&global_json_path) + } else { + None + } +} + +fn get_pinned_sdk_version_from_file(path: &Path) -> Option { + let json_text = crate::utils::read_file(path).ok()?; + log::debug!( + "Checking if .NET SDK version is pinned in: {}", + path.display() + ); + get_pinned_sdk_version(&json_text) +} + +fn get_pinned_sdk_version(json: &str) -> Option { + let parsed_json: JValue = serde_json::from_str(json).ok()?; + + match parsed_json { + JValue::Object(root) => { + let sdk = root.get("sdk")?; + match sdk { + JValue::Object(sdk) => { + let version = sdk.get("version")?; + match version { + JValue::String(version_string) => { + let mut buffer = String::with_capacity(version_string.len() + 1); + buffer.push('v'); + buffer.push_str(version_string); + Some(Version(buffer)) + } + _ => None, + } + } + _ => None, + } + } + _ => None, + } +} + +fn get_local_dotnet_files<'a>(context: &'a Context) -> Result>, std::io::Error> { + Ok(context + .get_dir_files()? + .iter() + .filter_map(|p| { + get_dotnet_file_type(p).map(|t| DotNetFile { + path: p.as_ref(), + file_type: t, + }) + }) + .collect()) +} + +fn get_dotnet_file_type(path: &Path) -> Option { + let file_name_lower = map_str_to_lower(path.file_name()); + + match file_name_lower.as_ref().map(|f| f.as_ref()) { + Some(GLOBAL_JSON_FILE) => return Some(FileType::GlobalJson), + Some(PROJECT_JSON_FILE) => return Some(FileType::ProjectJson), + _ => (), + }; + + let extension_lower = map_str_to_lower(path.extension()); + + match extension_lower.as_ref().map(|f| f.as_ref()) { + Some("sln") => return Some(FileType::SolutionFile), + Some("csproj") | Some("fsproj") | Some("xproj") => return Some(FileType::ProjectFile), + _ => (), + }; + + None +} + +fn map_str_to_lower(value: Option<&OsStr>) -> Option { + Some(value?.to_str()?.to_ascii_lowercase()) +} + +fn get_version_from_cli() -> Option { + let version_output = match Command::new("dotnet").arg("--version").output() { + Ok(output) => output, + Err(e) => { + log::warn!("Failed to execute `dotnet --version`. {}", e); + return None; + } + }; + let version = str::from_utf8(version_output.stdout.as_slice()) + .ok()? + .trim(); + + let mut buffer = String::with_capacity(version.len() + 1); + buffer.push('v'); + buffer.push_str(version); + + Some(Version(buffer)) +} + +fn get_latest_sdk_from_cli() -> Option { + let mut cmd = Command::new("dotnet"); + cmd.arg("--list-sdks") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .stdin(Stdio::null()); + + let exit_code = match cmd.status() { + Ok(status) => status, + Err(e) => { + log::warn!("Failed to execute `dotnet --list-sdks`. {}", e); + return None; + } + }; + + if exit_code.success() { + let sdks_output = cmd.output().ok()?; + fn parse_failed() -> Option { + log::warn!("Unable to parse the output from `dotnet --list-sdks`."); + None + }; + let latest_sdk = str::from_utf8(sdks_output.stdout.as_slice()) + .ok()? + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .last() + .or_else(parse_failed)?; + let take_until = latest_sdk.find('[').or_else(parse_failed)? - 1; + if take_until > 1 { + let version = &latest_sdk[..take_until]; + let mut buffer = String::with_capacity(version.len() + 1); + buffer.push('v'); + buffer.push_str(version); + Some(Version(buffer)) + } else { + parse_failed() + } + } else { + // Older versions of the dotnet cli do not support the --list-sdks command + // So, if the status code indicates failure, fall back to `dotnet --version` + log::warn!( + "Received a non-success exit code from `dotnet --list-sdks`. \ + Falling back to `dotnet --version`.", + ); + get_version_from_cli() + } +} + +struct DotNetFile<'a> { + path: &'a Path, + file_type: FileType, +} + +#[derive(PartialEq)] +enum FileType { + ProjectJson, + ProjectFile, + GlobalJson, + SolutionFile, +} + +struct Version(String); + +impl Deref for Version { + type Target = String; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[test] +fn should_parse_version_from_global_json() { + let json_text = r#" + { + "sdk": { + "version": "1.2.3" + } + } + "#; + + let version = get_pinned_sdk_version(json_text).unwrap(); + assert_eq!("v1.2.3", version.0); +} + +#[test] +fn should_ignore_empty_global_json() { + let json_text = "{}"; + + let version = get_pinned_sdk_version(json_text); + assert!(version.is_none()); +} diff --git a/starship/src/modules/mod.rs b/starship/src/modules/mod.rs index d5174ada..5a0b8ddc 100644 --- a/starship/src/modules/mod.rs +++ b/starship/src/modules/mod.rs @@ -3,6 +3,7 @@ mod aws; mod character; mod cmd_duration; mod directory; +mod dotnet; mod env_var; mod git_branch; mod git_state; @@ -31,32 +32,34 @@ use crate::module::Module; pub fn handle<'a>(module: &str, context: &'a Context) -> Option> { match module { + // Keep these ordered alphabetically. + // Default ordering is handled in configs/mod.rs "aws" => aws::module(context), + #[cfg(feature = "battery")] + "battery" => battery::module(context), "directory" => directory::module(context), - "env_var" => env_var::module(context), "character" => character::module(context), - "nodejs" => nodejs::module(context), - "rust" => rust::module(context), - "python" => python::module(context), - "ruby" => ruby::module(context), - "golang" => golang::module(context), - "line_break" => line_break::module(context), - "package" => package::module(context), + "cmd_duration" => cmd_duration::module(context), + "dotnet" => dotnet::module(context), + "env_var" => env_var::module(context), "git_branch" => git_branch::module(context), "git_state" => git_state::module(context), "git_status" => git_status::module(context), - "kubernetes" => kubernetes::module(context), - "username" => username::module(context), - #[cfg(feature = "battery")] - "battery" => battery::module(context), - "cmd_duration" => cmd_duration::module(context), + "golang" => golang::module(context), + "hostname" => hostname::module(context), "java" => java::module(context), "jobs" => jobs::module(context), - "nix_shell" => nix_shell::module(context), - "hostname" => hostname::module(context), - "time" => time::module(context), + "kubernetes" => kubernetes::module(context), + "line_break" => line_break::module(context), "memory_usage" => memory_usage::module(context), - + "nix_shell" => nix_shell::module(context), + "nodejs" => nodejs::module(context), + "package" => package::module(context), + "python" => python::module(context), + "ruby" => ruby::module(context), + "rust" => rust::module(context), + "time" => time::module(context), + "username" => username::module(context), _ => { eprintln!("Error: Unknown module {}. Use starship module --list to list out all supported modules.", module); None diff --git a/tests/Dockerfile b/tests/Dockerfile index 9e93f596..bb825a08 100644 --- a/tests/Dockerfile +++ b/tests/Dockerfile @@ -48,6 +48,19 @@ RUN curl https://pyenv.run | bash \ # Check that Python was correctly installed RUN python --version +# Install Dotnet +ENV DOTNET_HOME /home/nonroot/dotnet +ENV DOTNET_SDK_VERSION 2.2.402 + +RUN mkdir -p "$DOTNET_HOME" \ + && dotnet_download="$DOTNET_HOME/../dotnet.tar.gz" \ + && curl -SL --output "$dotnet_download" https://dotnetcli.blob.core.windows.net/dotnet/Sdk/$DOTNET_SDK_VERSION/dotnet-sdk-$DOTNET_SDK_VERSION-linux-x64.tar.gz \ + && tar -zxf "$dotnet_download" -C "$DOTNET_HOME" \ + && rm "$dotnet_download" + +ENV PATH $DOTNET_HOME:$PATH +RUN dotnet help + # Create blank project RUN USER=nonroot cargo new --bin /src/starship WORKDIR /src/starship diff --git a/tests/testsuite/dotnet.rs b/tests/testsuite/dotnet.rs new file mode 100644 index 00000000..d395b384 --- /dev/null +++ b/tests/testsuite/dotnet.rs @@ -0,0 +1,146 @@ +use super::common; +use std::fs::{DirBuilder, OpenOptions}; +use std::io::{self, Error, ErrorKind, Write}; +use std::process::{Command, Stdio}; +use tempfile::TempDir; + +#[test] +#[ignore] +fn shows_nothing_in_directory_with_zero_relevant_files() -> io::Result<()> { + let workspace = create_workspace(false)?; + expect_output(&workspace, ".", None) +} + +#[test] +#[ignore] +fn shows_latest_in_directory_with_solution() -> io::Result<()> { + let workspace = create_workspace(false)?; + touch_path(&workspace, "solution.sln", None)?; + expect_output(&workspace, ".", Some("•NET v2.2.402")) +} + +#[test] +#[ignore] +fn shows_latest_in_directory_with_csproj() -> io::Result<()> { + let workspace = create_workspace(false)?; + touch_path(&workspace, "project.csproj", None)?; + expect_output(&workspace, ".", Some("•NET v2.2.402")) +} + +#[test] +#[ignore] +fn shows_latest_in_directory_with_fsproj() -> io::Result<()> { + let workspace = create_workspace(false)?; + touch_path(&workspace, "project.fsproj", None)?; + expect_output(&workspace, ".", Some("•NET v2.2.402")) +} + +#[test] +#[ignore] +fn shows_latest_in_directory_with_xproj() -> io::Result<()> { + let workspace = create_workspace(false)?; + touch_path(&workspace, "project.xproj", None)?; + expect_output(&workspace, ".", Some("•NET v2.2.402")) +} + +#[test] +#[ignore] +fn shows_latest_in_directory_with_project_json() -> io::Result<()> { + let workspace = create_workspace(false)?; + touch_path(&workspace, "project.json", None)?; + expect_output(&workspace, ".", Some("•NET v2.2.402")) +} + +#[test] +#[ignore] +fn shows_pinned_in_directory_with_global_json() -> io::Result<()> { + let workspace = create_workspace(false)?; + let global_json = make_pinned_sdk_json("1.2.3"); + touch_path(&workspace, "global.json", Some(&global_json))?; + expect_output(&workspace, ".", Some("•NET v1.2.3")) +} + +#[test] +#[ignore] +fn shows_pinned_in_project_below_root_with_global_json() -> io::Result<()> { + let workspace = create_workspace(false)?; + let global_json = make_pinned_sdk_json("1.2.3"); + touch_path(&workspace, "global.json", Some(&global_json))?; + touch_path(&workspace, "project/project.csproj", None)?; + expect_output(&workspace, "project", Some("•NET v1.2.3")) +} + +#[test] +#[ignore] +fn shows_pinned_in_deeply_nested_project_within_repository() -> io::Result<()> { + let workspace = create_workspace(true)?; + let global_json = make_pinned_sdk_json("1.2.3"); + touch_path(&workspace, "global.json", Some(&global_json))?; + touch_path(&workspace, "deep/path/to/project/project.csproj", None)?; + expect_output(&workspace, "deep/path/to/project", Some("•NET v1.2.3")) +} + +fn create_workspace(is_repo: bool) -> io::Result { + let repo_dir = common::new_tempdir()?; + + if is_repo { + let mut command = Command::new("git"); + command + .args(&["init", "--quiet"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .stdin(Stdio::null()) + .current_dir(repo_dir.path()); + + if !command.status()?.success() { + return Err(Error::from(ErrorKind::Other)); + } + } + + Ok(repo_dir) +} + +fn touch_path(workspace: &TempDir, relative_path: &str, contents: Option<&str>) -> io::Result<()> { + let path = workspace.path().join(relative_path); + + DirBuilder::new().recursive(true).create( + path.parent() + .expect("Expected relative_path to be a file in a directory"), + )?; + + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&path)?; + write!(file, "{}", contents.unwrap_or("")) +} + +fn make_pinned_sdk_json(version: &str) -> String { + let json_text = r#" + { + "sdk": { + "version": "INSERT_VERSION" + } + } + "#; + json_text.replace("INSERT_VERSION", version) +} + +fn expect_output(workspace: &TempDir, run_from: &str, contains: Option<&str>) -> io::Result<()> { + let run_path = workspace.path().join(run_from); + let output = common::render_module("dotnet") + .current_dir(run_path) + .output()?; + let text = String::from_utf8(output.stdout).unwrap(); + + // This can be helpful for debugging + eprintln!("The dotnet module showed: {}", text); + + match contains { + Some(contains) => assert!(text.contains(contains)), + None => assert!(text.is_empty()), + } + + Ok(()) +} diff --git a/tests/testsuite/main.rs b/tests/testsuite/main.rs index 471f5332..f96eadcf 100644 --- a/tests/testsuite/main.rs +++ b/tests/testsuite/main.rs @@ -4,6 +4,7 @@ mod cmd_duration; mod common; mod configuration; mod directory; +mod dotnet; mod env_var; mod git_branch; mod git_state;