All pastes #225328 Raw Edit

PathEx path processing utility

public text v1 · immutable
#225328 ·published 2006-10-27 21:38 UTC
rendered paste body
// PathEx.cs: path processing functionality beyond what System.IO.Path offers.
using System;
using System.Text;
using System.IO;
using System.Diagnostics;

namespace PathEx
{
	/// <summary>
	/// Provides path processing functionality beyond what System.IO.Path offers.
	/// </summary>
	/// <remarks>
	/// Here are a couple of things System.IO.Path doesn't offer:
	///
	/// <code>
	/// PathEx.RemoveLastPath("/Windows/calc.exe") // = "/Windows"
	/// PathEx.RemoveLastPath("/Windows/calc.exe/") // = "/Windows"
	///
	/// PathEx.GetRelativePath("/Windows/System32", "/Windows/calc.exe") // = "../calc.exe"
	/// 
	/// PathEx.IsPathSeparator('\\') // = true
	/// PathEx.IsPathSeparator('/') // = true
	/// PathEx.IsPathSeparator('Q') // = false
	///
	/// PathEx.CollapsePath("//A/../B/./C") // = "//B/C"
	/// </code></remarks>
	public class PathEx
	{
		/// <summary>Returns c == '\\' || c == '/'.
		/// </summary>
		public static bool IsPathSeparator(char c)
			{ return c == '\\' || c == '/'; }
		
		/// <summary>
		/// Determines how much of the beginning of the path string 
		/// represents a drive, volume, protocol, or domain name, and returns
		/// it, including trailing slashes.  If the path does not appear to be
		/// an absolute path, then an empty string (not null) is returned.
		/// </summary>
		/// <param name="includeDomain">If this is true, then the return value 
		/// includes the computer or domain name; if false, then the return 
		/// value includes only the drive or protocol specification and the
		/// initial slash(es).
		/// <param name="wantTrailingSlash">If true, then the trailing slash on 
		/// a computer or domain name is included; otherwise, it is omitted.
		/// This parameter does not apply if if includeDomain is false.</param>
		/// </param>
		/// <remarks>
		/// This function assumes that the path is well-formed.  In other
		/// words, you can expect garbage-in-garbage-out.  The includeDomain
		/// parameter has an effect only if the function believes that the
		/// path includes a computer or domain name, which is indicated by
		/// the presence of more than one slash.  For example, in
		/// "file://A/B", there are two slashes, so A is assumed to be
		/// the "volume name".  On the other hand, in "C:/A/B", there is only
		/// one slash, so A is considered to be a folder name.  See the 
		/// examples below for the consequences of this interpretation.
		/// <p>Examples (where includeDomain and wantTrailingSlash are true 
		/// unless said otherwise):</p>
		/// <code>
		/// \Test                             => \
		/// \\ComputerName                    => \\ComputerName
		/// \\ComputerName                    => \\ (if !includeDomain)
		/// \\ComputerName\Test               => \\ComputerName\
		/// C:\Windows                        => C:\
		/// http://www.google.com/index.html  => http://www.google.com/
		/// http://www.google.com/index.html  => http://www.google.com (if !wantTrailingSlash)
		/// file:///C:/Windows                => file:///C:/
		/// file:///C:/Windows                => file:///C:/ (if !wantTrailingSlash)
		/// file:///C:/Windows                => file:///    (if !includeDomain)
		/// C:D:E                             => C:
		/// C:D/E                             => C:
		/// file///C:/Windows                 => (returns String.Empty)
		/// Relative/Path                     => (returns String.Empty)
		/// Hello                             => (returns String.Empty)
		/// </code>
		/// </remarks>
		public static string GetDriveOrDomain (string path, bool includeDomain, bool wantTrailingSlash)
		{
			return path.Substring(0, GetDriveOrDomainLen (path, includeDomain, wantTrailingSlash));
		}
		/// <summary>Behaves like <see cref="GetDriveOrDomain"/>, except that
		/// this function returns the length of the drive or volume string
		/// rather than the string itself.
		/// </summary>
		/// <param name="wantTrailingSlash">If true, then the trailing slash on 
		/// a computer or domain name is included; otherwise, it is omitted.
		/// This parameter does not apply if if includeDomain is false.</param>
		/// <returns>The number of characters at the beginning of the path
		/// which represent the drive or volume name</returns>
		public static int GetDriveOrDomainLen (string path, bool includeDomain, bool wantTrailingSlash)
		{
			int slashes = -1;
			int i;

			for (i = 0; ; i++) {
				if (i == path.Length) {
					// There are no colons or slashes found, so there is NO 
					// drive/protocol/domain in it.
					return 0;
				}
				if (path[i] == ':')
					break;
				if (IsPathSeparator(path[i])) {
					if (i == 0) {
						slashes++;
						break;
					} else
						return 0; // Relative path detected
				}
			}
			do {
				if (++i == path.Length) return i;
				slashes++;
			} while (IsPathSeparator(path[i]));

			if (slashes > 1 && includeDomain) {
				// Multiple slashes indicates a domain (or volume) name.

				// Find out where the name of the volume/domain ends.
				do
					if (++i == path.Length) return i;
				while (!IsPathSeparator(path[i]));
				
				if (wantTrailingSlash)
					++i;
			}
			return i;
		}
		
		/// <summary>Removes the last path component.  Returns the same
		/// string if the path string ends in multiple slashes.  Also, if 
		/// stopAtColon is true, returns the same path if the final path
		/// component ends in a colon (e.g. X/Y/Z: or C:\)
		/// </summary>
		/// <remarks>
		/// Equivalent to path.Substring(0, FindBeginningOfLastPath(path)).
		/// Examples (assuming stopAtColon is true):
		/// <code>
		/// C:\                                 => C:\
		/// C:\                                 => (empty string if !stopAtColon)
		/// http://                             => http://
		/// test//                              => test//
		/// test/                               => (empty string)
		/// http://www.google.ca                => http://
		/// http://www.google.ca/               => http://
		/// http://www.google.ca/index.html     => http://www.google.ca/
		/// C:\Test                             => C:\
		/// C:\A\B                              => C:\A\
		/// C:\A\B\                             => C:\A\
		/// </code></remarks>
		public static string RemoveLastPath (string path, bool stopAtColon)
		{
			return path.Substring(0, FindBeginningOfLastPath(path, stopAtColon));
		}
		public static string RemoveLastPath (string path)
		{
			return path.Substring(0, FindBeginningOfLastPath(path, false));
		}
		
		/// <summary>
		/// Equivalent to FindBeginningOfPriorPath(path, path.Length).
		/// </summary>
		public static int FindBeginningOfLastPath (string path, bool stopAtColon)
		{
			return FindBeginningOfPriorPath(path, path.Length, stopAtColon);
		}

		public static int FindBeginningOfPriorPath (string path, int startAt, bool stopAtColon)
		{
			int i = startAt - 1;
			if (i < 0 || (path[i] == ':' && stopAtColon))
				return startAt;
			if (IsPathSeparator(path[i])) {
				i--;
				if (i < 0 || IsPathSeparator(path[i]) 
					|| (path[i] == ':' && stopAtColon))
					return startAt;
			}
			for (i--; i >= 0 && !IsPathSeparator(path[i]); i--) { }
			return i + 1;
		}
		
//		private static bool BeginningsMatch(string s1, string s2, int len)
//			{ return string.Compare(s1, 0, s2, 0, len, true) == 0; }

		protected static char GetPathSeparator(string path)
		{
			for (int i = 0; i < path.Length; i++)
				if (IsPathSeparator(path[i])) return path[i];
			return Path.PathSeparator;
		}
		protected static bool AreEqual(char c1, char c2, bool caseSensitive)
		{
			if (c1 == c2)
				return true;
			if (caseSensitive)
				return false;
			if (c1 >= 'a' && c1 <= 'z')
				return c1 - ('a' - 'A') == c2;
			else
				return false;
		}

		/// <summary>Converts an absolute path to a relative path, given
		/// a starting path.  In a sense, "from" is "subtracted" from "to".
		/// Combining, a.k.a. "adding" the return value to <i>from</i> should 
		/// yield <i>to</i>.
		/// </summary>
		/// <param name="from">Path from which to start.</param>
		/// <param name="to">Destination path</param>
		/// <param name="requireSameRootFolder">If this is true, then 'from' 
		/// and 'to' must have the same root folder in order to successfully
		/// generate a relative path between them.  If a relative path cannot
		/// be generated, then 'to' itself is returned.
		/// If this is false, then a relative path is generated even if 'from'
		/// and 'to' have different root folders.
		/// </param>
		/// <param name="allowRelativePaths">If this is true, and 'from' and
		/// 'to' are relative paths, then GetRelativePath attempts to find
		/// a relative path between them.  If allowRelativePaths is false,
		/// and 'from' and 'to' are relative paths, then GetRelativePath simply
		/// returns 'to' unchanged.
		/// </param>
		/// <returns>The path that gets you from 'from' to 'to'.</returns>
		/// <remarks>In order for this function to work reliably, 'from' and
		/// 'to' must use the same path separator character.  In other words,
		/// they both must use a backslash to separate paths or else they both 
		/// must use a forward slash.
		/// </remarks>
		/// <example><code>
		/// Examples might not be perfect.
		/// 
		/// value of 'from'  value of 'to'  return value
		/// ---------------  -------------  ------------
		/// A:\B\C\D         A:\B\C\D       (empty string)
		/// A:\B\C\D         A:\B\C\D\E     E
		/// A:\B\C\D         A:\B\C         ..
		/// A:\B\C\D         A:\            ..\..\..   (if requireSameRootFolder)
		/// A:\B\C\D         A:\            A:\        (if !requireSameRootFolder)
		/// A/B/C            A/B/D          A/B/D      (if !allowRelativePaths)
		/// A/B/C            A/B/D          ../D       (if allowRelativePaths)
		/// A:\B\C\D         \B\C\E         \B\C\E
		/// \B\C\D           \B\C\E         ..\E
		/// \B\C\D           \B\C\D\E\      E\
		/// \B\C\D           \B\C\E\        ..\E\
		/// \B\C             \D\E\          C:\D\E\    (if requireSameRootFolder)
		/// \B\C             \D\E\          ..\..\D\E\ (if !requireSameRootFolder)
		/// http://W/X       http://Y/Z     http://Y/Z
		/// http://W/X       http://W/Y     http://W/Y (if requireSameRootFolder)
		/// http://W/X       http://W/Y     ../Y       (if !requireSameRootFolder)
		/// </code></example>
		public static string GetRelativePath (string from, string to, bool requireSameRootFolder, bool allowRelativePaths, bool caseSensitive)
		{
			int driveEndsAt = GetDriveOrDomainLen(from, true, true);
			int driveEndsAt2 = GetDriveOrDomainLen(to, true, true);
			if (driveEndsAt != driveEndsAt2 || (driveEndsAt == 0 && !allowRelativePaths))
				return to; // Two different domains (no relative path exists),
					// or one of the paths is relative (relative path is unknown)
			
			char separator = GetPathSeparator(from);
			
			int difStart = 0; // The beginning of the difference(s)

			// Find out where they first diverge.
			for (int i = 0; ; i++) {
				if (i == from.Length) {
					if (i == to.Length) {
						// The strings are equal!
						return string.Empty;
					} else {
						if (IsPathSeparator(to[i]))
							difStart = i + 1;
						break;
					}
				}
				if (i == to.Length) {
					if (IsPathSeparator(from[i]))
						difStart = i + 1;
					break;
				}
				if (!AreEqual(from[i], to[i], caseSensitive))
					break;
				if (IsPathSeparator(from[i]))
					difStart = i + 1;
			}
			if (difStart < driveEndsAt)
				return to; // Two different domains (no relative path exists)
			if (requireSameRootFolder && difStart == driveEndsAt)
				return to; // Two different root folders

			StringBuilder relPath = new StringBuilder();

			// At this point we know that we're going to make a relative path.
			// This is a two-step process: 
			// (1) add ..'s until all the excess parts of 'from' are gone
			bool foundOne = true;
			for (int i = difStart; i < from.Length; i++) {
				if (foundOne) {
					foundOne = false;
					relPath.Append("..");
					relPath.Append(separator);
				}
				if (IsPathSeparator(from[i]))
					foundOne = true;
			}
			
			// (2) add the end of 'to'
			if (difStart < to.Length)
				relPath.Append(to, difStart, to.Length - difStart);

			return relPath.ToString();
		}

		/// <summary>Adds path2 on the end of path1 in a similar manner to 
		/// Path.Combine(), with the exception that path components of 
		/// "." and ".." are removed.
		/// </summary>
		public static string Combine(string path1, string path2) {
			return CollapsePath(Path.Combine(path1, path2));
		}
		
		/// <summary>
		/// Removes path components named "." and "..".  Since ".." represents
		/// "going back to the parent folder", when a ".." is discovered, both
		/// it and the immediately prior component in the string are deleted.
		/// </summary>
		/// <example><code>
		/// Input                Output
		/// -----                ------
		/// C:\Test              C:\Test
		/// \Hello\..            \
		/// \Hello\..\           \
		/// Hello\..             (empty string)
		/// Hello\.              Hello\
		/// A\.\B\.\C\           A\B\C\
		/// A\..\B\C             B\C
		/// \\A\..\B\C           \\B\C
		/// http://test/..       http://
		/// </code></example>
		public static string CollapsePath(string path)
		{
			int pathStart = GetDriveOrDomainLen(path, false, true);
			
			// Begin at the end.
			int i = path.Length;
			while (i > pathStart) {
				int newI = FindBeginningOfPriorPath(path, i, false);
				bool reachedLimit = false;
				Debug.Assert (newI >= pathStart);
				if (newI == i) // Unable to proceed further backward
					reachedLimit = true;
				i = newI;
				if (path[i] == '.') {
					if (i + 1 < path.Length) {
						if (IsPathSeparator(path[i + 1]))
							path = path.Remove(i, 2);
						else if (path[i + 1] == '.') {
							// Found "..".
							if (reachedLimit)
								break; // Unable to proceed further.

							// Confirm that it is a complete path component.
							int endI = i + 2;
							if (endI < path.Length) {
								if (IsPathSeparator(path[endI]))
									endI++;
								else
									continue;
							}

							int priorI = FindBeginningOfPriorPath(path, i, false);
							if (priorI == i) // Unable to proceed further.
								return path;
							
							path = path.Remove(priorI, endI - priorI);
							i = priorI;
						}
					} else
						path = path.Remove(i, 1);
				}
				if (reachedLimit)
					break;
			}
			return path;
		}
		private PathEx() {}

	}
	public class PathExTest
	{
		protected class OneTest {
			public OneTest(string In, string _Out) { Path1 = In; Out = _Out; }
			public OneTest(string In, string _Out, bool flag1) { Path1 = In; Out = _Out; Flag1 = flag1; }
			public OneTest(string In, string _Out, bool flag1, bool flag2) { Path1 = In; Out = _Out; Flag1 = flag1; Flag2 = flag2; }
			public OneTest(string path1, string path2, string _Out) { Path1 = path1; Path2 = path2; Out = _Out; }
			public OneTest(string path1, string path2, string _Out, bool flag1) { Path1 = path1; Path2 = path2; Out = _Out; Flag1 = flag1; }
			public OneTest(string path1, string path2, string _Out, bool flag1, bool flag2) { Path1 = path1; Path2 = path2; Out = _Out; Flag1 = flag1; Flag2 = flag2; }
			public OneTest(string path1, string path2, string _Out, string _Out2) { Path1 = path1; Path2 = path2; Out = _Out; Out2 = _Out2; }
			public OneTest(string path1, string path2, string _Out, string _Out2, bool flag1) { Path1 = path1; Path2 = path2; Out = _Out; Out2 = _Out2; Flag1 = flag1; }
			public string Path { get { return Path1; } }
			public string Path1;
			public string Path2;
			public string Out;
			public string Out2;
			public bool Flag1;
			public bool Flag2;
		}
		
		protected static OneTest[] testsGetRelPath = new OneTest[]
		{   //  path1, path2, if !requireSameRootFolder, if requireSameRootFolder, allowRelativePaths
			new OneTest(@"A:\B\C\D",     @"A:\B\C\D",    @"",           @"",        false),
			new OneTest(@"A:\B\C\D",     @"A:\B\C\D\E",  @"E",          @"E",       false),
			new OneTest(@"A:\B\C\D",     @"A:\B\C",      @"..\",        @"..\",     false),
			new OneTest(@"A:\B\C\D",     @"A:\",         @"..\..\..\",  @"A:\",     false),
			new OneTest(@"A:\B\C\D\",    @"A:\",         @"..\..\..\",  @"A:\",      true),
			new OneTest(@"A:\B\C\D\",    @"A:",          @"A:",         @"A:",       true),
			new OneTest(@"A/B/C",        @"A/B/D",       @"A/B/D",      @"A/B/D",   false),
			new OneTest(@"A/B/C/",       @"A/B/D/",      @"../D/",      @"../D/",    true),
			new OneTest(@"A:\B\C\D",     @"\B\C\E",      @"\B\C\E",     @"\B\C\E",  false),
			new OneTest(@"\B\C\D",       @"\B\C\E",      @"..\E",       @"..\E",    false),
			new OneTest(@"\B\C\D",       @"\B\C\D\E\",   @"E\",         @"E\",      false),
			new OneTest(@"\B\C\D",       @"\B\C\E\",     @"..\E\",      @"..\E\",   false),
			new OneTest(@"\B\C",         @"\D\E\",       @"..\..\D\E\", @"\D\E\",   false),
			new OneTest(@"http://W/X",   @"http://Y/Z",  @"http://Y/Z", @"http://Y/Z", false),
			new OneTest(@"http://W/X",   @"http://W/Y",  @"../Y",       @"http://W/Y", false),
		};

		protected static OneTest[] testsGetDriveOrDomain = new OneTest[]
		{ // input, output, includeDomain, wantTrailingSlash
			new OneTest(@"Hello",                             @"",            true, true),
			new OneTest(@"\Test",                             @"\",           true, true),
			new OneTest(@"\\ComputerName",                    @"\\ComputerName", true, true),
			new OneTest(@"\\ComputerName",                    @"\\",          false, true),
			new OneTest(@"\\ComputerName",                    @"\\",          false, false),
			new OneTest(@"\\ComputerName\Test",               @"\\ComputerName\", true, true),
			new OneTest(@"C:\Windows",                        @"C:\",         true, true),
			new OneTest(@"http://www.google.com/index.html",  @"http://www.google.com/", true, true),
			new OneTest(@"http://www.google.com/index.html",  @"http://www.google.com", true, false),
			new OneTest(@"file:///C:/Windows",                @"file:///C:/", true, true),
			new OneTest(@"file:///C:/Windows",                @"file:///C:",  true, false),
			new OneTest(@"file:///C:/Windows",                @"file:///",    false, true),
			new OneTest(@"file:///C:/Windows",                @"file:///",    false, false),
			new OneTest(@"C:D:E",                             @"C:",          true, true),
			new OneTest(@"C:D/E",                             @"C:",          true, true),
			new OneTest(@"file///C:/Windows",                 @"",            true, true),
			new OneTest(@"Relative/Path",                     @"",            true, true),
		};

		protected static OneTest[] testsRemoveLastPath = new OneTest[]
		{
			new OneTest(@"C:\",                              @"C:\", true),
			new OneTest(@"C:\",                              @"", false),
			new OneTest(@"Alpha/Beta:/",                     @"Alpha/Beta:/", true),
			new OneTest(@"Alpha/Beta:/",                     @"Alpha/", false),
			new OneTest(@"http://",                          @"http://", true),
			new OneTest(@"test//",                           @"test//", true),
			new OneTest(@"test/",                            @"", true),
			new OneTest(@"http://www.google.ca",             @"http://", true),
			new OneTest(@"http://www.google.ca/",            @"http://", true),
			new OneTest(@"http://www.google.ca/index.html",  @"http://www.google.ca/", true),
			new OneTest(@"C:\Test",                          @"C:\", true),
			new OneTest(@"C:\A\B",                           @"C:\A\", true),
			new OneTest(@"C:\A\B\",                          @"C:\A\", true),
		};
		
		protected static OneTest[] testsCollapsePath = new OneTest[]
		{
			new OneTest(@"C:\Test",               @"C:\Test"),
			new OneTest(@"\Hello\..",             @"\"),
			new OneTest(@"\Hello\..\",            @"\"),
			new OneTest(@"Hello\..",              @""),
			new OneTest(@"Hello\.",               @"Hello\"),
			new OneTest(@"A\.\B\.\C\",            @"A\B\C\"),
			new OneTest(@"A\..\B\C",              @"B\C"),
			new OneTest(@"\\A\..\B\C",            @"\\B\C"),
			new OneTest(@"http://test/..",        @"http://"),
		};

		public static void DoTests()
		{
			int i;
			string result;
			OneTest t;

			for (i = 0; i < testsGetDriveOrDomain.Length; i++) {
				t = testsGetDriveOrDomain[i];
				result = PathEx.GetDriveOrDomain (t.Path1, t.Flag1, t.Flag2);
				Debug.Assert (result == t.Out);
			}
			
			for (i = 0; i < testsGetRelPath.Length; i++) {
				t = testsGetRelPath[i];
				
				result = PathEx.GetRelativePath (t.Path1, t.Path2, 
					false, t.Flag1, false);
				Debug.Assert (result == t.Out);
				
				result = PathEx.GetRelativePath (t.Path1, t.Path2, 
					true, t.Flag1, false);
				Debug.Assert (result == t.Out2);
			}
			
			for (i = 0; i < testsRemoveLastPath.Length; i++) {
				t = testsRemoveLastPath[i];
				result = PathEx.RemoveLastPath(t.Path1, t.Flag1);
				Debug.Assert (result == t.Out);
			}
			
			for (i = 0; i < testsCollapsePath.Length; i++) {
				t = testsCollapsePath[i];
				result = PathEx.CollapsePath (t.Path1);
				Debug.Assert (result == t.Out);
			}
		}
	}
}