-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathTopicCollection.cs
More file actions
378 lines (325 loc) · 17.8 KB
/
Copy pathTopicCollection.cs
File metadata and controls
378 lines (325 loc) · 17.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
// Copyright (C) Kampute
//
// Released under the terms of the MIT license.
// See the LICENSE file in the project root for the full license text.
namespace Kampute.DocToolkit.Collections
{
using Kampute.DocToolkit.Models;
using Kampute.DocToolkit.Support;
using Kampute.DocToolkit.Topics;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
/// <summary>
/// Represents a collection of top-level topics in a documentation context.
/// </summary>
/// <remarks>
/// The <see cref="TopicCollection"/> class provides functionality for managing top-level topics in a documentation context.
/// It maintains topics in an efficient dictionary structure with case-insensitive keys, enabling fast lookups for a topic in
/// the topic hierarchy by its qualified identifier or URI reference.
/// </remarks>
/// <threadsafety static="true" instance="false"/>
public class TopicCollection : IReadOnlyTopicCollection
{
private readonly List<TopicModel> rootTopics = [];
private readonly Dictionary<string, TopicModel> allTopics = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, TopicModel> topicsByPath = new(StringComparer.OrdinalIgnoreCase);
private readonly Func<IDocumentationContext, ITopic, TopicModel> topicModelFactory;
/// <summary>
/// Initializes a new instance of the <see cref="TopicCollection"/> class.
/// </summary>
/// <param name="context">The documentation context to associate with this collection of topics.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="context"/> is <see langword="null"/>.</exception>
public TopicCollection(IDocumentationContext context)
: this(context, static (ctx, src) => new TopicModel(ctx, src))
{
}
/// <summary>
/// Initializes a new instance of the <see cref="TopicCollection"/> class with a custom topic factory.
/// </summary>
/// <param name="context">The documentation context to associate with this collection of topics.</param>
/// <param name="modelFactory">The factory function to create model instances for topics in the collection.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="context"/> or <paramref name="modelFactory"/> is <see langword="null"/>.</exception>
public TopicCollection(IDocumentationContext context, Func<IDocumentationContext, ITopic, TopicModel> modelFactory)
{
Context = context ?? throw new ArgumentNullException(nameof(context));
topicModelFactory = modelFactory ?? throw new ArgumentNullException(nameof(modelFactory));
}
/// <summary>
/// Initializes a new instance of the <see cref="TopicCollection"/> class with initial topics.
/// </summary>
/// <param name="context">The documentation context to associate with this collection of topics.</param>
/// <param name="topics">The initial topics to add to the collection.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="context"/> or <paramref name="topics"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="topics"/> contains a topic that is not a top-level topic.</exception>
public TopicCollection(IDocumentationContext context, IEnumerable<ITopic> topics)
: this(context)
{
if (topics is null)
throw new ArgumentNullException(nameof(topics));
foreach (var topic in topics)
Add(topic);
}
/// <summary>
/// Initializes a new instance of the <see cref="TopicCollection"/> class with a custom topic factory and initial topics.
/// </summary>
/// <param name="context">The documentation context to associate with this collection of topics.</param>
/// <param name="modelFactory">The factory function to create model instances for topics in the collection.</param>
/// <param name="topics">The initial topics to add to the collection.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="context"/>, <paramref name="modelFactory"/>, or <paramref name="topics"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="topics"/> contains a topic that is not a top-level topic.</exception>
public TopicCollection(IDocumentationContext context, Func<IDocumentationContext, ITopic, TopicModel> modelFactory, IEnumerable<ITopic> topics)
: this(context, modelFactory)
{
if (topics is null)
throw new ArgumentNullException(nameof(topics));
foreach (var topic in topics)
Add(topic);
}
/// <summary>
/// Gets the documentation context associated with this collection of topics.
/// </summary>
/// <value>
/// The <see cref="IDocumentationContext"/> that this collection belongs to.
/// </value>
public IDocumentationContext Context { get; }
/// <summary>
/// Gets the number of top-level topics in the collection.
/// </summary>
/// <value>
/// The number of topics in the collection.
/// </value>
public int Count => rootTopics.Count;
/// <summary>
/// Gets all topics in the collection, including all nested topics.
/// </summary>
/// <value>
/// The read-only collection of all topics in the collection, including nested topics.
/// </value>
public IReadOnlyCollection<TopicModel> Flatten => allTopics.Values;
/// <summary>
/// Adds a top-level topic and all its subtopics to the collection.
/// </summary>
/// <param name="topLevelTopic">The topic to add.</param>
/// <returns><see langword="true"/> if the topic was added successfully; otherwise, <see langword="false"/>, indicating that a topic with the same identifier already exists.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="topLevelTopic"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="topLevelTopic"/> has a parent topic, indicating that it is not a top-level topic.</exception>
public bool Add(ITopic topLevelTopic)
{
if (topLevelTopic is null)
throw new ArgumentNullException(nameof(topLevelTopic));
if (topLevelTopic.ParentTopic is not null)
throw new ArgumentException("The topic must be a top-level topic without a parent.", nameof(topLevelTopic));
if (allTopics.ContainsKey(topLevelTopic.Id))
return false;
var contextualTopic = topicModelFactory(Context, topLevelTopic);
rootTopics.Add(contextualTopic);
allTopics.Add(contextualTopic.Id, contextualTopic);
if (topLevelTopic is IFileBasedTopic fileTopic)
topicsByPath.TryAdd(NormalizePathFormat(fileTopic.FilePath), contextualTopic);
AddSubtopics(contextualTopic);
return true;
void AddSubtopics(TopicModel topic)
{
foreach (var subtopic in topic.Subtopics)
{
allTopics.TryAdd(subtopic.Id, subtopic);
if (subtopic.Source is IFileBasedTopic fileSubtopic)
topicsByPath.TryAdd(NormalizePathFormat(fileSubtopic.FilePath), subtopic);
if (subtopic.Subtopics.Count > 0)
AddSubtopics(subtopic);
}
}
static string NormalizePathFormat(string path) => path.Replace('\\', '/');
}
/// <summary>
/// Removes a top-level topic and all its subtopics from the collection.
/// </summary>
/// <param name="topLevelTopic">The topic to remove.</param>
/// <returns><see langword="true"/> if the topic was successfully removed from the collection; otherwise, <see langword="false"/></returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="topLevelTopic"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="topLevelTopic"/> is not a top-level topic.</exception>
public bool Remove(TopicModel topLevelTopic)
{
if (topLevelTopic is null)
throw new ArgumentNullException(nameof(topLevelTopic));
if (topLevelTopic.ParentTopic is not null)
throw new ArgumentException("The topic must be a top-level topic without a parent.", nameof(topLevelTopic));
if (ReferenceEquals(topLevelTopic.Context, Context) && rootTopics.Remove(topLevelTopic))
{
allTopics.Remove(topLevelTopic.Id);
if (topLevelTopic is IFileBasedTopic fileTopic)
topicsByPath.Remove(fileTopic.FilePath);
RemoveSubtopics(topLevelTopic);
return true;
}
return false;
void RemoveSubtopics(TopicModel topic)
{
foreach (var subtopic in topic.Subtopics)
{
allTopics.Remove(subtopic.Id);
if (subtopic is IFileBasedTopic fileTopic)
topicsByPath.Remove(fileTopic.FilePath);
if (subtopic.Subtopics.Count > 0)
RemoveSubtopics(subtopic);
}
}
}
/// <summary>
/// Removes all topics from the collection.
/// </summary>
public void Clear()
{
rootTopics.Clear();
allTopics.Clear();
topicsByPath.Clear();
}
/// <summary>
/// Attempts to resolve a topic in the collection based on the provided reference string.
/// </summary>
/// <param name="reference">The reference string used to identify the topic, such as a file path or identifier.</param>
/// <param name="topic">When this method returns, contains the resolved topic if found; otherwise, <see langword="null"/>.</param>
/// <returns><see langword="true"/> if the topic was successfully resolved; otherwise, <see langword="false"/>.</returns>
/// <inheritdoc/>
public bool TryResolve(string reference, [NotNullWhen(true)] out TopicModel? topic)
{
if (Count == 0 || string.IsNullOrEmpty(reference))
{
topic = null;
return false;
}
var isAbsolute = reference.StartsWith('/');
if (!isAbsolute && Context.AddressProvider.ActiveScope.Model is TopicModel referee)
{
// If referee is a file-backed topic, attempt to resolve the reference as a relative file path to it
if (referee.Source is IFileBasedTopic fileTopic)
{
var absolutePath = Path.Combine(Path.GetDirectoryName(fileTopic.FilePath)!, reference);
if (TryGetByFilePath(absolutePath, out topic))
return true;
}
// If the reference is a valid identifier relative to the referee topic, attempt to resolve it
if (PathHelper.TryNormalizePath(referee.Id + '/' + reference, out var qualifiedId))
{
if (TryGetById(qualifiedId, out topic))
return true;
}
}
// When reference is an absolute file path or identifier, attempt to resolve it directly
if ((isAbsolute && reference.Length > 1) || !PathHelper.HasDotSegment(reference))
{
if (isAbsolute)
reference = reference[1..];
if (TryGetById(reference, out topic))
return true;
if (TryFindBySubpath(reference, out topic))
return true;
if (TryFindByPartialId(reference, out topic))
return true;
}
topic = null;
return false;
}
/// <summary>
/// Attempts to find a topic in the topic hierarchy by its qualified identifier.
/// </summary>
/// <param name="id">The qualified identifier of the topic to lookup.</param>
/// <param name="topic">When this method returns, contains the topic if found; otherwise, <see langword="null"/>.</param>
/// <returns><see langword="true"/> if the topic was found; otherwise, <see langword="false"/>.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="id"/> is <see langword="null"/>.</exception>
/// <inheritdoc/>
public bool TryGetById(string id, [NotNullWhen(true)] out TopicModel? topic)
{
if (id is null)
throw new ArgumentNullException(nameof(id));
return allTopics.TryGetValue(id, out topic);
}
/// <summary>
/// Attempts to lookup a file-backed topic in the topic hierarchy by its full path.
/// </summary>
/// <param name="filePath">The file path of the topic to lookup.</param>
/// <param name="topic">When this method returns, contains the topic if found; otherwise, <see langword="null"/>.</param>
/// <returns><see langword="true"/> if the topic was found; otherwise, <see langword="false"/>.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="filePath"/> is <see langword="null"/>.</exception>
/// <inheritdoc/>
public bool TryGetByFilePath(string filePath, [NotNullWhen(true)] out TopicModel? topic)
{
if (filePath is null)
throw new ArgumentNullException(nameof(filePath));
if (PathHelper.TryNormalizePath(filePath, out var normalizedPath))
return topicsByPath.TryGetValue(normalizedPath, out topic);
topic = null;
return false;
}
/// <summary>
/// Attempts to find a file-backed topic in the topic hierarchy by its subpaths.
/// </summary>
/// <param name="filePath">The file path or subpath of the topic to lookup.</param>
/// <param name="topic">When this method returns, contains the topic that uniquely matches the specified file path or subpath; otherwise, <see langword="null"/> if no match or if ambiguous.</param>
/// <returns><see langword="true"/> if a unique matching topic was found; otherwise, <see langword="false"/>.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="filePath"/> is <see langword="null"/> or empty.</exception>
public bool TryFindBySubpath(string filePath, [NotNullWhen(true)] out TopicModel? topic)
{
if (string.IsNullOrEmpty(filePath))
throw new ArgumentException($"{nameof(filePath)} cannot be null or empty.", nameof(filePath));
topic = null;
if (!PathHelper.TryNormalizePath(filePath, out var subPath))
return false;
foreach (var (sourceFilePath, fileBasedTopic) in topicsByPath)
{
if (PathHelper.IsSubpath(sourceFilePath, subPath))
{
if (topic is not null)
{
// Ambiguous match
topic = null;
return false;
}
topic = fileBasedTopic;
}
}
return topic is not null;
}
/// <summary>
/// Attempts to find a topic in the topic hierarchy by its unqualified identifier.
/// </summary>
/// <param name="id">The unqualified identifier the topic to lookup.</param>
/// <param name="topic">When this method returns, contains the topic that uniquely matches the specified identifier; otherwise, <see langword="null"/> if no match or if ambiguous.</param>
/// <returns><see langword="true"/> if a unique matching topic was found; otherwise, <see langword="false"/>.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="id"/> is <see langword="null"/> or empty.</exception>
public bool TryFindByPartialId(string id, [NotNullWhen(true)] out TopicModel? topic)
{
if (string.IsNullOrEmpty(id))
throw new ArgumentException($"{nameof(id)} cannot be null or empty.", nameof(id));
topic = null;
foreach (var candidate in allTopics.Values)
{
if (!candidate.Id.EndsWith(id, StringComparison.Ordinal))
continue;
if (candidate.Id.Length > id.Length && candidate.Id[candidate.Id.Length - id.Length - 1] != '/')
continue;
if (topic is not null)
{
// Ambiguous match
topic = null;
return false;
}
topic = candidate;
}
return topic is not null;
}
/// <summary>
/// Returns an enumerator that iterates through the collection.
/// </summary>
/// <returns>An enumerator that can be used to iterate through the collection of top-level topics.</returns>
public IEnumerator<TopicModel> GetEnumerator() => rootTopics.GetEnumerator();
/// <summary>
/// Returns an enumerator that iterates through the collection.
/// </summary>
/// <returns>An enumerator that can be used to iterate through the collection.</returns>
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
}