Add a `nodoc` configuration option to allow declaring entire files to not be documented (#2369)

* Add a nodoc dartdoc configuration option with tests

* Add documentation for the nodoc configuration option.

* Fix bug on empty doc comment

* Sprinkle canonicalization fairy dust on paths for Windows

* more fairy dust

* pre-cleanup windows fix

* Tighten up Windows support and add a test

* Delete obsolete comment

* Re-add dart:io for Platform
diff --git a/README.md b/README.md
index f201eac..c9f0bc6 100644
--- a/README.md
+++ b/README.md
@@ -109,6 +109,7 @@
   categoryOrder: ["First Category", "Second Category"]
   examplePathPrefix: 'subdir/with/examples'
   includeExternal: ['bin/unusually_located_library.dart']
+  nodoc: ['lib/sekret/*.dart']
   linkTo:
     url: "https://my.dartdocumentationsite.org/dev/%v%"
   showUndocumentedCategories: true
@@ -136,7 +137,7 @@
     directives.
   * **exclude**:  Specify a list of library names to avoid generating docs for,
     overriding any specified in include.  All libraries listed must be local to this package, unlike
-    the command line `--exclude`.
+    the command line `--exclude`.  See also `nodoc`.
   * **errors**:  Specify warnings to be treated as errors.  See the lists of valid warnings in the command
     line help for `--errors`, `--warnings`, and `--ignore`.
   * **favicon**:  A path to a favicon for the generated docs.
@@ -175,6 +176,11 @@
       * `%f%`:  Relative path of file to the repository root
       * `%r%`:  Revision
       * `%l%`:  Line number
+  * **nodoc**: Specify files (via globs) which should be treated as though they have the `@nodoc`
+    tag in the documentation comment of every defined element.  Unlike `exclude` this can specify
+    source files directly, and neither inheritance nor reexports will cause these elements to be
+    documented when included in other libraries.  For more fine-grained control, use `@nodoc` in
+    element documentation comments directly, or the `exclude` directive.
   * **warnings**:  Specify otherwise ignored or set-to-error warnings to simply warn.  See the lists
     of valid warnings in the command line help for `--errors`, `--warnings`, and `--ignore`.
 
diff --git a/lib/src/dartdoc_options.dart b/lib/src/dartdoc_options.dart
index b60c8f0..be76f89 100644
--- a/lib/src/dartdoc_options.dart
+++ b/lib/src/dartdoc_options.dart
@@ -1489,6 +1489,8 @@
   // ignore: unused_element
   String get _linkToHosted => optionSet['linkTo']['hosted'].valueAt(context);
 
+  List<String> get nodoc => optionSet['nodoc'].valueAt(context);
+
   String get output => optionSet['output'].valueAt(context);
 
   PackageMeta get packageMeta => optionSet['packageMeta'].valueAt(context);
@@ -1669,6 +1671,11 @@
             help: 'Allow links to be generated for packages outside this one.',
             negatable: true),
       ]),
+    DartdocOptionFileOnly<List<String>>('nodoc', [], resourceProvider,
+        optionIs: OptionKind.glob,
+        help: 'Dart symbols declared in these '
+            'files will be treated as though they have the @nodoc flag added to '
+            'their documentation comment.'),
     DartdocOptionArgOnly<String>('output',
         resourceProvider.pathContext.join('doc', 'api'), resourceProvider,
         optionIs: OptionKind.dir, help: 'Path to output directory.'),
diff --git a/lib/src/model/documentation_comment.dart b/lib/src/model/documentation_comment.dart
index 7db25be..81ddd2e 100644
--- a/lib/src/model/documentation_comment.dart
+++ b/lib/src/model/documentation_comment.dart
@@ -47,10 +47,14 @@
   String computeDocumentationComment();
 
   /// Returns true if the raw documentation comment has a nodoc indication.
-  bool get hasNodoc =>
-      documentationComment != null &&
-      (documentationComment.contains('@nodoc') ||
-          documentationComment.contains('<nodoc>'));
+  bool get hasNodoc {
+    if (documentationComment != null &&
+        (documentationComment.contains('@nodoc') ||
+            documentationComment.contains('<nodoc>'))) {
+      return true;
+    }
+    return packageGraph.configSetsNodocFor(element.source.fullName);
+  }
 
   /// Process a [documentationComment], performing various actions based on
   /// `{@}`-style directives, except `{@tool}`, returning the processed result.
diff --git a/lib/src/model/model_element.dart b/lib/src/model/model_element.dart
index 0f25cfa..d3e0bf4 100644
--- a/lib/src/model/model_element.dart
+++ b/lib/src/model/model_element.dart
@@ -472,11 +472,7 @@
           !(enclosingElement as Extension).isPublic) {
         _isPublic = false;
       } else {
-        if (documentationComment == null) {
-          _isPublic = utils.hasPublicName(element);
-        } else {
-          _isPublic = utils.hasPublicName(element) && !hasNodoc;
-        }
+        _isPublic = utils.hasPublicName(element) && !hasNodoc;
       }
     }
     return _isPublic;
diff --git a/lib/src/model/package_graph.dart b/lib/src/model/package_graph.dart
index ba3d9a9..ba30ef6 100644
--- a/lib/src/model/package_graph.dart
+++ b/lib/src/model/package_graph.dart
@@ -22,6 +22,7 @@
 import 'package:dartdoc/src/special_elements.dart';
 import 'package:dartdoc/src/tuple.dart';
 import 'package:dartdoc/src/warnings.dart';
+import 'package:dartdoc/src/model_utils.dart' show matchGlobs;
 
 class PackageGraph {
   PackageGraph.UninitializedPackageGraph(
@@ -950,6 +951,25 @@
         allLocalModelElements.where((e) => e.isCanonical).toList();
   }
 
+  /// Glob lookups can be expensive.  Cache per filename.
+  final _configSetsNodocFor = HashMap<String, bool>();
+
+  /// Given an element's location, look up the nodoc configuration data and
+  /// determine whether to unconditionally treat the element as "nodoc".
+  bool configSetsNodocFor(String fullName) {
+    if (!_configSetsNodocFor.containsKey(fullName)) {
+      var file = resourceProvider.getFile(fullName);
+      // Direct lookup instead of generating a custom context will save some
+      // cycles.  We can't use the element's [DartdocOptionContext] because that
+      // might not be where the element was defined, which is what's important
+      // for nodoc's semantics.  Looking up the defining element just to pull
+      // a context is again, slow.
+      List<String> globs = config.optionSet['nodoc'].valueAt(file.parent);
+      _configSetsNodocFor[fullName] = matchGlobs(globs, fullName);
+    }
+    return _configSetsNodocFor[fullName];
+  }
+
   String getMacro(String name) {
     assert(_localDocumentationBuilt);
     return _macros[name];
diff --git a/lib/src/model_utils.dart b/lib/src/model_utils.dart
index f798b6c..77a1432 100644
--- a/lib/src/model_utils.dart
+++ b/lib/src/model_utils.dart
@@ -5,15 +5,56 @@
 library dartdoc.model_utils;
 
 import 'dart:convert';
+import 'dart:io' show Platform;
 
 import 'package:analyzer/dart/ast/ast.dart';
 import 'package:analyzer/dart/element/element.dart';
 import 'package:analyzer/file_system/file_system.dart';
 import 'package:analyzer/src/dart/ast/utilities.dart';
+import 'package:dartdoc/dartdoc.dart';
 import 'package:dartdoc/src/model/model.dart';
+import 'package:path/path.dart' as path;
+import 'package:glob/glob.dart';
+
+final _driveLetterMatcher = RegExp(r'^\w:\\');
 
 final Map<String, String> _fileContents = <String, String>{};
 
+/// This will handle matching globs, including on Windows.
+///
+/// On windows, globs are assumed to use absolute Windows paths with drive
+/// letters in combination with globs, e.g. `C:\foo\bar\*.txt`.  `fullName`
+/// also is assumed to have a drive letter.
+bool matchGlobs(List<String> globs, String fullName, {bool isWindows}) {
+  isWindows ??= Platform.isWindows;
+  var filteredGlobs = <String>[];
+
+  if (isWindows) {
+    // TODO(jcollins-g): port this special casing to the glob package.
+    var fullNameDriveLetter = _driveLetterMatcher.stringMatch(fullName);
+    if (fullNameDriveLetter == null) {
+      throw DartdocFailure(
+          'Unable to recognize drive letter on Windows in:  $fullName');
+    }
+    // Build a matcher from the [fullName]'s drive letter to filter the globs.
+    var driveGlob = RegExp(fullNameDriveLetter.replaceFirst(r'\', r'\\'),
+        caseSensitive: false);
+    fullName = fullName.replaceFirst(_driveLetterMatcher, r'\');
+    for (var glob in globs) {
+      // Globs don't match if they aren't for the same drive.
+      if (!driveGlob.hasMatch(glob)) continue;
+      // `C:\` => `\` for rejoining via posix.
+      glob = glob.replaceFirst(_driveLetterMatcher, r'/');
+      filteredGlobs.add(path.posix.joinAll(path.windows.split(glob)));
+    }
+  } else {
+    filteredGlobs.addAll(globs);
+  }
+
+  return filteredGlobs.any((g) =>
+      Glob(g, context: isWindows ? path.windows : null).matches(fullName));
+}
+
 /// Returns the [AstNode] for a given [Element].
 ///
 /// Uses a precomputed map of [element.source.fullName] to [CompilationUnit]
diff --git a/pubspec.yaml b/pubspec.yaml
index 6dd8160..fb316fd 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -12,6 +12,7 @@
   collection: ^1.2.0
   cli_util: '>=0.1.4 <0.3.0'
   crypto: ^2.0.6
+  glob: '>=1.1.2 <2.0.0'
   html: '>=0.12.1 <0.15.0'
   logging: ^0.11.3+1
   markdown: ^2.1.5
@@ -29,7 +30,6 @@
   build_version: ^2.0.1
   coverage: ^0.14.0
   dhttpd: ^3.0.0
-  glob: ^1.1.5
   grinder: ^0.8.2
   http: ^0.12.0
   pedantic: ^1.9.0
diff --git a/test/dartdoc_options_test.dart b/test/dartdoc_options_test.dart
index c454e42..0a7b1cb 100644
--- a/test/dartdoc_options_test.dart
+++ b/test/dartdoc_options_test.dart
@@ -383,13 +383,20 @@
     });
 
     group('glob options', () {
+      String canonicalize(String path) =>
+          resourceProvider.pathContext.canonicalize(path);
+
       test('work via the command line', () {
-        dartdocOptionSetAll.parseArguments(['--glob-option', 'foo/**']);
+        dartdocOptionSetAll
+            .parseArguments(['--glob-option', path.join('foo', '**')]);
         expect(
             dartdocOptionSetAll['globOption'].valueAtCurrent(),
             equals([
-              resourceProvider.pathContext
-                  .join(resourceProvider.pathContext.current, 'foo/**')
+              resourceProvider.pathContext.joinAll([
+                canonicalize(resourceProvider.pathContext.current),
+                'foo',
+                '**'
+              ])
             ]));
       });
 
@@ -398,22 +405,26 @@
         expect(
             dartdocOptionSetAll['globOption'].valueAt(secondDir),
             equals([
-              resourceProvider.pathContext.join(secondDir.path, 'q*.html'),
-              resourceProvider.pathContext.join(secondDir.path, 'e*.dart')
+              canonicalize(
+                  resourceProvider.pathContext.join(secondDir.path, 'q*.html')),
+              canonicalize(
+                  resourceProvider.pathContext.join(secondDir.path, 'e*.dart')),
             ]));
         // No child override, should be the same as parent
         expect(
             dartdocOptionSetAll['globOption'].valueAt(secondDirSecondSub),
             equals([
-              resourceProvider.pathContext.join(secondDir.path, 'q*.html'),
-              resourceProvider.pathContext.join(secondDir.path, 'e*.dart')
+              canonicalize(
+                  resourceProvider.pathContext.join(secondDir.path, 'q*.html')),
+              canonicalize(
+                  resourceProvider.pathContext.join(secondDir.path, 'e*.dart')),
             ]));
         // Child directory overrides
         expect(
             dartdocOptionSetAll['globOption'].valueAt(secondDirFirstSub),
             equals([
-              resourceProvider.pathContext
-                  .join(secondDirFirstSub.path, '**/*.dart')
+              resourceProvider.pathContext.joinAll(
+                  [canonicalize(secondDirFirstSub.path), '**', '*.dart'])
             ]));
       });
     });
diff --git a/test/end2end/model_test.dart b/test/end2end/model_test.dart
index d116a5a..70e1f69 100644
--- a/test/end2end/model_test.dart
+++ b/test/end2end/model_test.dart
@@ -849,6 +849,22 @@
       });
     });
 
+    group('Comment processing', () {
+      test('can virtually add nodoc via options file', () {
+        var NodocMeLibrary = packageGraph.defaultPackage.allLibraries
+            .firstWhere((l) => l.name == 'nodocme');
+        expect(NodocMeLibrary.hasNodoc, isTrue);
+        var NodocMeImplementation = fakeLibrary.allClasses
+            .firstWhere((c) => c.name == 'NodocMeImplementation');
+        expect(NodocMeImplementation.hasNodoc, isTrue);
+        expect(NodocMeImplementation.isPublic, isFalse);
+        var MeNeitherEvenWithoutADocComment = fakeLibrary.allClasses
+            .firstWhere((c) => c.name == 'MeNeitherEvenWithoutADocComment');
+        expect(MeNeitherEvenWithoutADocComment.hasNodoc, isTrue);
+        expect(MeNeitherEvenWithoutADocComment.isPublic, isFalse);
+      });
+    });
+
     group('doc references', () {
       String docsAsHtml;
 
diff --git a/test/model_utils_test.dart b/test/model_utils_test.dart
index 7bc35cd..850c2d4 100644
--- a/test/model_utils_test.dart
+++ b/test/model_utils_test.dart
@@ -8,6 +8,22 @@
 import 'package:test/test.dart';
 
 void main() {
+  group('match glob', () {
+    test('basic POSIX', () {
+      expect(
+          matchGlobs(['/a/b/*', '/b/c/*'], '/b/c/d', isWindows: false), isTrue);
+      expect(matchGlobs(['/q/r/s'], '/foo', isWindows: false), isFalse);
+    });
+
+    test('basic Windows', () {
+      expect(matchGlobs([r'C:\a\b\*'], r'c:\a\b\d', isWindows: true), isTrue);
+    });
+
+    test('Windows does not pass for different drive letters', () {
+      expect(matchGlobs([r'C:\a\b\*'], r'D:\a\b\d', isWindows: true), isFalse);
+    });
+  });
+
   group('model_utils stripIndentFromSource', () {
     test('no indent', () {
       expect(stripIndentFromSource('void foo() {\n  print(1);\n}\n'),
diff --git a/testing/test_package/dartdoc_options.yaml b/testing/test_package/dartdoc_options.yaml
index 2e1d9d5..97922cd 100644
--- a/testing/test_package/dartdoc_options.yaml
+++ b/testing/test_package/dartdoc_options.yaml
@@ -7,6 +7,7 @@
     Unreal:
       markdown: "Unreal.md"
     Real Libraries:
+  nodoc: ["lib/src/nodoc*.dart"]
   tools:
     drill:
       command: ["bin/drill.dart"]
diff --git a/testing/test_package/lib/fake.dart b/testing/test_package/lib/fake.dart
index 6493d36..004599b 100644
--- a/testing/test_package/lib/fake.dart
+++ b/testing/test_package/lib/fake.dart
@@ -60,6 +60,9 @@
 import 'two_exports.dart' show BaseClass;
 export 'src/notadotdartfile';
 
+// Verify that even though reexported, objects don't show in documentation.
+export 'package:test_package/src/nodocme.dart';
+
 // ignore: uri_does_not_exist
 export 'package:test_package_imported/categoryExporting.dart'
     show IAmAClassWithCategories;
diff --git a/testing/test_package/lib/src/nodocme.dart b/testing/test_package/lib/src/nodocme.dart
new file mode 100644
index 0000000..457dcaa
--- /dev/null
+++ b/testing/test_package/lib/src/nodocme.dart
@@ -0,0 +1,11 @@
+///
+/// The library nodocme should never have any members documented, even if
+/// reexported, due to dartdoc_options.yaml in the package root.
+///
+library nodocme;
+
+/// I should not appear in documentation.
+class NodocMeImplementation {}
+
+class MeNeitherEvenWithoutADocComment {}
+