feat: Add dotnet module (#416)

Adds a .NET module, which preferentially parses local/git files to get
the .NET version.
This commit is contained in:
Nick Young 2019-10-02 16:56:49 +10:00 committed by Kevin Song
parent f14392b5ea
commit 6621e4c859
11 changed files with 587 additions and 28 deletions

View File

@ -71,6 +71,11 @@ jobs:
with: with:
python-version: "3.6.9" 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 # Run the ignored tests that expect the above setup
- uses: actions/checkout@master - uses: actions/checkout@master
- name: Run all tests - name: Run all tests
@ -88,6 +93,8 @@ jobs:
- name: Fix file permissions - name: Fix file permissions
run: chmod -R a+w . run: chmod -R a+w .
- name: Build the Docker image - 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 - name: Run tests in Docker
run: docker run --rm -v $(pwd):/src/starship starshipcommand/starship-test run: docker run --rm -v $(pwd):/src/starship starshipcommand/starship-test

View File

@ -91,12 +91,13 @@ prompt_order = [
"git_state", "git_state",
"git_status", "git_status",
"package", "package",
"nodejs", "dotnet",
"ruby",
"rust",
"python",
"golang", "golang",
"java", "java",
"nodejs",
"python",
"ruby",
"rust",
"nix_shell", "nix_shell",
"memory_usage", "memory_usage",
"aws", "aws",
@ -317,6 +318,41 @@ it would have been `nixpkgs/pkgs`.
truncation_length = 8 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 ## Environment Variable
The `env_var` module displays the current value of a selected environment variable. The `env_var` module displays the current value of a selected environment variable.

View File

@ -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,
}
}
}

View File

@ -1,4 +1,5 @@
pub mod battery; pub mod battery;
pub mod dotnet;
pub mod rust; pub mod rust;
use crate::config::{ModuleConfig, RootModuleConfig}; use crate::config::{ModuleConfig, RootModuleConfig};
@ -26,12 +27,16 @@ impl<'a> RootModuleConfig<'a> for StarshipRootConfig<'a> {
"git_state", "git_state",
"git_status", "git_status",
"package", "package",
"nodejs", // ↓ Toolchain version modules ↓
"ruby", // (Let's keep these sorted alphabetically)
"rust", "dotnet",
"python",
"golang", "golang",
"java", "java",
"nodejs",
"python",
"ruby",
"rust",
// ↑ Toolchain version modules ↑
"nix_shell", "nix_shell",
"memory_usage", "memory_usage",
"aws", "aws",

View File

@ -5,6 +5,8 @@ use ansi_term::{ANSIString, ANSIStrings};
use std::fmt; use std::fmt;
// List of all modules // List of all modules
// Keep these ordered alphabetically.
// Default ordering is handled in configs/mod.rs
pub const ALL_MODULES: &[&str] = &[ pub const ALL_MODULES: &[&str] = &[
"aws", "aws",
#[cfg(feature = "battery")] #[cfg(feature = "battery")]
@ -12,6 +14,7 @@ pub const ALL_MODULES: &[&str] = &[
"character", "character",
"cmd_duration", "cmd_duration",
"directory", "directory",
"dotnet",
"env_var", "env_var",
"git_branch", "git_branch",
"git_state", "git_state",

View File

@ -122,7 +122,7 @@ fn contract_path(full_path: &Path, top_level_path: &Path, top_level_replacement:
/// On non-Windows OS, does nothing /// On non-Windows OS, does nothing
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn replace_c_dir(path: String) -> String { fn replace_c_dir(path: String) -> String {
return path.replace("C:/", "/c"); path.replace("C:/", "/c")
} }
/// Replaces "C://" with "/c/" within a Windows path /// Replaces "C://" with "/c/" within a Windows path

View File

@ -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<Module<'a>> {
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<Version> {
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<Version> {
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::<Vec<&Path>>();
// 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<Version> {
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<Version> {
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<Version> {
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<Vec<DotNetFile<'a>>, 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<FileType> {
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<String> {
Some(value?.to_str()?.to_ascii_lowercase())
}
fn get_version_from_cli() -> Option<Version> {
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<Version> {
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<T>() -> Option<T> {
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());
}

View File

@ -3,6 +3,7 @@ mod aws;
mod character; mod character;
mod cmd_duration; mod cmd_duration;
mod directory; mod directory;
mod dotnet;
mod env_var; mod env_var;
mod git_branch; mod git_branch;
mod git_state; mod git_state;
@ -31,32 +32,34 @@ use crate::module::Module;
pub fn handle<'a>(module: &str, context: &'a Context) -> Option<Module<'a>> { pub fn handle<'a>(module: &str, context: &'a Context) -> Option<Module<'a>> {
match module { match module {
// Keep these ordered alphabetically.
// Default ordering is handled in configs/mod.rs
"aws" => aws::module(context), "aws" => aws::module(context),
#[cfg(feature = "battery")]
"battery" => battery::module(context),
"directory" => directory::module(context), "directory" => directory::module(context),
"env_var" => env_var::module(context),
"character" => character::module(context), "character" => character::module(context),
"nodejs" => nodejs::module(context), "cmd_duration" => cmd_duration::module(context),
"rust" => rust::module(context), "dotnet" => dotnet::module(context),
"python" => python::module(context), "env_var" => env_var::module(context),
"ruby" => ruby::module(context),
"golang" => golang::module(context),
"line_break" => line_break::module(context),
"package" => package::module(context),
"git_branch" => git_branch::module(context), "git_branch" => git_branch::module(context),
"git_state" => git_state::module(context), "git_state" => git_state::module(context),
"git_status" => git_status::module(context), "git_status" => git_status::module(context),
"kubernetes" => kubernetes::module(context), "golang" => golang::module(context),
"username" => username::module(context), "hostname" => hostname::module(context),
#[cfg(feature = "battery")]
"battery" => battery::module(context),
"cmd_duration" => cmd_duration::module(context),
"java" => java::module(context), "java" => java::module(context),
"jobs" => jobs::module(context), "jobs" => jobs::module(context),
"nix_shell" => nix_shell::module(context), "kubernetes" => kubernetes::module(context),
"hostname" => hostname::module(context), "line_break" => line_break::module(context),
"time" => time::module(context),
"memory_usage" => memory_usage::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); eprintln!("Error: Unknown module {}. Use starship module --list to list out all supported modules.", module);
None None

View File

@ -48,6 +48,19 @@ RUN curl https://pyenv.run | bash \
# Check that Python was correctly installed # Check that Python was correctly installed
RUN python --version 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 # Create blank project
RUN USER=nonroot cargo new --bin /src/starship RUN USER=nonroot cargo new --bin /src/starship
WORKDIR /src/starship WORKDIR /src/starship

146
tests/testsuite/dotnet.rs Normal file
View File

@ -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<TempDir> {
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(())
}

View File

@ -4,6 +4,7 @@ mod cmd_duration;
mod common; mod common;
mod configuration; mod configuration;
mod directory; mod directory;
mod dotnet;
mod env_var; mod env_var;
mod git_branch; mod git_branch;
mod git_state; mod git_state;