Repository: JanDeDobbeleer/oh-my-posh
Pull request #3528 (2023-02-25 03:12:09)

feat: add Unity segment
### Prerequisites

- [x] I have read and understood the [contributing guide][CONTRIBUTING.md]
- [x] The commit message follows the [conventional commits][cc] guidelines
- [x] Tests for the changes have been added (for bug fixes/features)
- [x] Docs have been added/updated (for bug fixes/features)

### Description

adds a new segment that displays the active Unity and C# versions

I'm sure there will be feedback to address but ready to kick off the review process!


Patch:
diff --git a/src/engine/segment.go b/src/engine/segment.go
index ae1d86a8..41f306d6 100644
--- a/src/engine/segment.go
+++ b/src/engine/segment.go
@@ -224,6 +224,8 @@ const (
 	TIME SegmentType = "time"
 	// UI5 Tooling segment
 	UI5TOOLING SegmentType = "ui5tooling"
+	// UNITY writes which Unity version is currently active
+	UNITY SegmentType = "unity"
 	// VALA writes the active vala version
 	VALA SegmentType = "vala"
 	// WAKATIME writes tracked time spend in dev editors
@@ -305,6 +307,7 @@ var Segments = map[SegmentType]func() SegmentWriter{
 	TEXT:          func() SegmentWriter { return &segments.Text{} },
 	TIME:          func() SegmentWriter { return &segments.Time{} },
 	UI5TOOLING:    func() SegmentWriter { return &segments.UI5Tooling{} },
+	UNITY:         func() SegmentWriter { return &segments.Unity{} },
 	VALA:          func() SegmentWriter { return &segments.Vala{} },
 	WAKATIME:      func() SegmentWriter { return &segments.Wakatime{} },
 	WINREG:        func() SegmentWriter { return &segments.WindowsRegistry{} },
diff --git a/src/segments/unity.go b/src/segments/unity.go
new file mode 100644
index 00000000..e6c95ccd
--- /dev/null
+++ b/src/segments/unity.go
@@ -0,0 +1,112 @@
+package segments
+
+import (
+	"errors"
+	"fmt"
+	"path/filepath"
+	"strings"
+
+	"github.com/jandedobbeleer/oh-my-posh/src/platform"
+	"github.com/jandedobbeleer/oh-my-posh/src/properties"
+	"github.com/jandedobbeleer/oh-my-posh/src/regex"
+)
+
+type Unity struct {
+	props properties.Properties
+	env   platform.Environment
+
+	UnityVersion  string
+	CSharpVersion string
+}
+
+func (u *Unity) Enabled() bool {
+	unityVersion, err := u.GetUnityVersion()
+	if err != nil {
+		u.env.Error(err)
+		return false
+	}
+	if len(unityVersion) == 0 {
+		return false
+	}
+	u.UnityVersion = unityVersion
+
+	csharpVersion, err := u.GetCSharpVersion()
+	if err != nil {
+		u.env.Error(err)
+	}
+	u.CSharpVersion = csharpVersion
+
+	return true
+}
+
+func (u *Unity) GetUnityVersion() (version string, err error) {
+	projectDir, err := u.env.HasParentFilePath("ProjectSettings")
+	if err != nil {
+		u.env.Debug("No ProjectSettings parent folder found")
+		return
+	}
+
+	if !u.env.HasFilesInDir(projectDir.Path, "ProjectVersion.txt") {
+		u.env.Debug("No ProjectVersion.txt file found")
+		return
+	}
+
+	versionFilePath := filepath.Join(projectDir.Path, "ProjectVersion.txt")
+	versionFileText := u.env.FileContent(versionFilePath)
+
+	firstLine := strings.Split(versionFileText, "\n")[0]
+	versionPrefix := "m_EditorVersion: "
+	versionPrefixIndex := strings.Index(firstLine, versionPrefix)
+	if versionPrefixIndex == -1 {
+		err := errors.New("ProjectSettings/ProjectVersion.txt is missing 'm_EditorVersion: ' prefix")
+		return "", err
+	}
+
+	versionStartIndex := versionPrefixIndex + len(versionPrefix)
+	unityVersion := firstLine[versionStartIndex:]
+
+	return strings.TrimSuffix(unityVersion, "f1"), nil
+}
+
+func (u *Unity) GetCSharpVersion() (version string, err error) {
+	lastDotIndex := strings.LastIndex(u.UnityVersion, ".")
+	if lastDotIndex == -1 {
+		return "", errors.New("lastDotIndex")
+	}
+	shortUnityVersion := u.UnityVersion[0:lastDotIndex]
+
+	if val, found := u.env.Cache().Get(shortUnityVersion); found {
+		csharpVersion := strings.TrimSuffix(val, ".0")
+		return csharpVersion, nil
+	}
+
+	url := fmt.Sprintf("https://docs.unity3d.com/%s/Documentation/Manual/CSharpCompiler.html", shortUnityVersion)
+	httpTimeout := u.props.GetInt(properties.HTTPTimeout, 2000)
+
+	body, err := u.env.HTTPRequest(url, nil, httpTimeout)
+	if err != nil {
+		return "", err
+	}
+
+	pageContent := string(body)
+
+	pattern := `<a href="https://(?:docs|learn)\.microsoft\.com/en-us/dotnet/csharp/whats-new/csharp-[0-9]+-?[0-9]*">(?P<csharpVersion>.*)</a>`
+	matches := regex.FindNamedRegexMatch(pattern, pageContent)
+	if matches != nil && matches["csharpVersion"] != "" {
+		csharpVersion := strings.TrimSuffix(matches["csharpVersion"], ".0")
+		u.env.Cache().Set(shortUnityVersion, csharpVersion, -1)
+		return csharpVersion, nil
+	}
+
+	u.env.Cache().Set(shortUnityVersion, "", -1)
+	return "", nil
+}
+
+func (u *Unity) Template() string {
+	return " \ue721 {{ .UnityVersion }}{{ if .CSharpVersion }} {{ .CSharpVersion }}{{ end }} "
+}
+
+func (u *Unity) Init(props properties.Properties, env platform.Environment) {
+	u.props = props
+	u.env = env
+}
diff --git a/themes/schema.json b/themes/schema.json
index a66a0b4e..0066695b 100644
--- a/themes/schema.json
+++ b/themes/schema.json
@@ -301,6 +301,7 @@
             "text",
             "terraform",
             "ui5tooling",
+            "unity",
             "wakatime",
             "winreg",
             "withings",
@@ -2752,6 +2753,26 @@
             }
           }
         },
+        {
+          "if": {
+            "properties": {
+              "type": { "const": "unity" }
+            }
+          },
+          "then": {
+            "title": "Unity Segment",
+            "description": "https://ohmyposh.dev/docs/unity",
+            "properties": {
+              "properties": {
+                "properties": {
+                  "http_timeout": {
+                    "$ref": "#/definitions/http_timeout"
+                  }
+                }
+              }
+            }
+          }
+        },
         {
           "if": {
             "properties": {
diff --git a/website/docs/segments/unity.mdx b/website/docs/segments/unity.mdx
new file mode 100644
index 00000000..bc60f274
--- /dev/null
+++ b/website/docs/segments/unity.mdx
@@ -0,0 +1,59 @@
+---
+id: unity
+title: Unity
+sidebar_label: Unity
+---
+
+## What
+
+Display the currently active [Unity][unity] and C# versions.
+
+The Unity version is displayed regardless of whether or not the C# version fetching succeeds.
+
+The C# version is fetched from [the docs][unity-csharp-page], but the page only exists as far back as Unity 2018.3.
+Earlier versions will not display a C# version.
+
+A web request only occurs the first time a given `major.minor` Unity version is encountered.
+Subsequent invocations return the cached C# version.
+
+## Sample Configuration
+
+```json
+{
+  "type": "unity",
+  "style": "powerline",
+  "powerline_symbol": "\ue0b0",
+  "foreground": "#111111",
+  "background": "#ffffff",
+  "properties": {
+    "http_timeout": 2000
+  }
+}
+```
+
+## Properties
+
+| Name            | Type     | Description                                     |
+| --------------- | -------- | ----------------------------------------------- |
+| `http_timeout`  | `int`    | The default timeout for http request is 2000ms. |
+
+## Template ([info][templates])
+
+:::note default template
+
+```template
+\ue721 {{ .UnityVersion }}{{ if .CSharpVersion }} {{ .CSharpVersion }}{{ end }}
+```
+
+:::
+
+### Properties
+
+| Name             | Type     | Description                              |
+| ---------------- | -------- | ---------------------------------------- |
+| `.UnityVersion`  | `string` | the Unity version                        |
+| `.CSharpVersion` | `string` | the C# version                           |
+
+[unity]: https://unity.com/
+[templates]: /docs/configuration/templates
+[unity-csharp-page]: https://docs.unity3d.com/Manual/CSharpCompiler.html
diff --git a/website/sidebars.js b/website/sidebars.js
index d8bc0f89..499ab8d6 100644
--- a/website/sidebars.js
+++ b/website/sidebars.js
@@ -115,6 +115,7 @@ module.exports = {
         "segments/text",
         "segments/time",
         "segments/ui5tooling",
+        "segments/unity",
         "segments/vala",
         "segments/wakatime",
         "segments/withings",


Test patch:
diff --git a/src/segments/unity_test.go b/src/segments/unity_test.go
new file mode 100644
index 00000000..4cb9a75e
--- /dev/null
+++ b/src/segments/unity_test.go
@@ -0,0 +1,188 @@
+package segments
+
+import (
+	"errors"
+	"fmt"
+	"path/filepath"
+	"testing"
+
+	mock2 "github.com/stretchr/testify/mock"
+
+	"github.com/jandedobbeleer/oh-my-posh/src/mock"
+	"github.com/jandedobbeleer/oh-my-posh/src/platform"
+	"github.com/jandedobbeleer/oh-my-posh/src/properties"
+
+	"github.com/stretchr/testify/assert"
+)
+
+type CacheGet struct {
+	key   string
+	val   string
+	found bool
+}
+
+type CacheSet struct {
+	key string
+	val string
+}
+
+type HTTPResponse struct {
+	body string
+	err  error
+}
+
+func TestUnitySegment(t *testing.T) {
+	cases := []struct {
+		Case                string
+		ExpectedOutput      string
+		VersionFileText     string
+		CacheGet            CacheGet
+		CacheSet            CacheSet
+		ExpectedToBeEnabled bool
+		VersionFileExists   bool
+		HTTPResponse        HTTPResponse
+	}{
+		{
+			Case:                "C# version cached",
+			ExpectedOutput:      "\ue721 2021.3.16 C# 9",
+			ExpectedToBeEnabled: true,
+			VersionFileExists:   true,
+			VersionFileText:     "m_EditorVersion: 2021.3.16f1\nm_EditorVersionWithRevision: 2021.3.16f1 (4016570cf34f)",
+			CacheGet: CacheGet{
+				key:   "2021.3",
+				val:   "C# 9",
+				found: true,
+			},
+		},
+		{
+			Case:                "C# version not cached",
+			ExpectedOutput:      "\ue721 2021.3.16 C# 9",
+			ExpectedToBeEnabled: true,
+			VersionFileExists:   true,
+			VersionFileText:     "m_EditorVersion: 2021.3.16f1\nm_EditorVersionWithRevision: 2021.3.16f1 (4016570cf34f)",
+			CacheGet: CacheGet{
+				key:   "2021.3",
+				val:   "",
+				found: false,
+			},
+			CacheSet: CacheSet{
+				key: "2021.3",
+				val: "C# 9",
+			},
+			HTTPResponse: HTTPResponse{
+				body: `<a href="https://docs.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-9">C# 9.0</a>`,
+				err:  nil,
+			},
+		},
+		{
+			Case:                "C# version has a minor version",
+			ExpectedOutput:      "\ue721 2021.3.16 C# 7.3",
+			ExpectedToBeEnabled: true,
+			VersionFileExists:   true,
+			VersionFileText:     "m_EditorVersion: 2021.3.16f1\nm_EditorVersionWithRevision: 2021.3.16f1 (4016570cf34f)",
+			CacheGet: CacheGet{
+				key:   "2021.3",
+				val:   "",
+				found: false,
+			},
+			CacheSet: CacheSet{
+				key: "2021.3",
+				val: "C# 7.3",
+			},
+			HTTPResponse: HTTPResponse{
+				body: `<a href="https://docs.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-7-3">C# 7.3</a>`,
+				err:  nil,
+			},
+		},
+		{
+			Case:                "C# version not found in webpage",
+			ExpectedOutput:      "\ue721 2021.3.16",
+			ExpectedToBeEnabled: true,
+			VersionFileExists:   true,
+			VersionFileText:     "m_EditorVersion: 2021.3.16f1\nm_EditorVersionWithRevision: 2021.3.16f1 (4016570cf34f)",
+			CacheGet: CacheGet{
+				key:   "2021.3",
+				val:   "",
+				found: false,
+			},
+			CacheSet: CacheSet{
+				key: "2021.3",
+				val: "",
+			},
+			HTTPResponse: HTTPResponse{
+				body: `<h1>Sorry... that page seems to be missing!</h1>`,
+				err:  nil,
+			},
+		},
+		{
+			Case:                "http request fails",
+			ExpectedOutput:      "\ue721 2021.3.16",
+			ExpectedToBeEnabled: true,
+			VersionFileExists:   true,
+			VersionFileText:     "m_EditorVersion: 2021.3.16f1\nm_EditorVersionWithRevision: 2021.3.16f1 (4016570cf34f)",
+			CacheGet: CacheGet{
+				key:   "2021.3",
+				val:   "",
+				found: false,
+			},
+			HTTPResponse: HTTPResponse{
+				body: "",
+				err:  errors.New("FAIL"),
+			},
+		},
+		{
+			Case:                "ProjectSettings/ProjectVersion.txt doesn't exist",
+			ExpectedToBeEnabled: false,
+			VersionFileExists:   false,
+		},
+		{
+			Case:                "ProjectSettings/ProjectVersion.txt is empty",
+			ExpectedToBeEnabled: false,
+			VersionFileExists:   true,
+			VersionFileText:     "",
+		},
+		{
+			Case:                "ProjectSettings/ProjectVersion.txt does not have expected format",
+			ExpectedToBeEnabled: false,
+			VersionFileExists:   true,
+			VersionFileText:     "2021.3.16f1",
+		},
+	}
+
+	for _, tc := range cases {
+		env := new(mock.MockedEnvironment)
+		env.On("Error", mock2.Anything).Return()
+		env.On("Debug", mock2.Anything)
+
+		err := errors.New("no match at root level")
+		var projectDir *platform.FileInfo
+		if tc.VersionFileExists {
+			err = nil
+			projectDir = &platform.FileInfo{
+				ParentFolder: "UnityProjectRoot",
+				Path:         "UnityProjectRoot/ProjectSettings",
+				IsDir:        true,
+			}
+			env.On("HasFilesInDir", projectDir.Path, "ProjectVersion.txt").Return(tc.VersionFileExists)
+			versionFilePath := filepath.Join(projectDir.Path, "ProjectVersion.txt")
+			env.On("FileContent", versionFilePath).Return(tc.VersionFileText)
+		}
+		env.On("HasParentFilePath", "ProjectSettings").Return(projectDir, err)
+
+		cache := &mock.MockedCache{}
+		cache.On("Get", tc.CacheGet.key).Return(tc.CacheGet.val, tc.CacheGet.found)
+		cache.On("Set", tc.CacheSet.key, tc.CacheSet.val, -1).Return()
+		env.On("Cache").Return(cache)
+
+		url := fmt.Sprintf("https://docs.unity3d.com/%s/Documentation/Manual/CSharpCompiler.html", tc.CacheGet.key)
+		env.On("HTTPRequest", url).Return([]byte(tc.HTTPResponse.body), tc.HTTPResponse.err)
+
+		props := properties.Map{}
+		unity := &Unity{}
+		unity.Init(props, env)
+		assert.Equal(t, tc.ExpectedToBeEnabled, unity.Enabled())
+		if tc.ExpectedToBeEnabled {
+			assert.Equal(t, tc.ExpectedOutput, renderTemplate(env, unity.Template(), unity), tc.Case)
+		}
+	}
+}