packages feed

ghc-tags-plugin 0.6.2.0 → 1.0.0.0

raw patch · 20 files changed

+2467/−331 lines, 20 filesdep +containersdep +deepseqdep +ghc-boot-thdep ~basedep ~bytestringdep ~directoryPVP ok

version bump matches the API change (PVP)

Dependencies added: containers, deepseq, ghc-boot-th, quickcheck-instances, tasty, tasty-hunit, tasty-quickcheck, temporary

Dependency ranges changed: base, bytestring, directory, filepath, ghc, ghc-tags-core, ghc-tags-pipes, optparse-applicative, pipes, text

API changes (from Hackage documentation)

- Plugin.GhcTags.FileLock: withFileLock :: Bool -> FilePath -> LockMode -> (FD -> IO x) -> IO x
+ Plugin.GhcTags: [filterOption] :: Options (f :: Type -> Type) -> Filter
+ Plugin.GhcTags: [qualified] :: Options (f :: Type -> Type) -> Bool
+ Plugin.GhcTags.FileLock: withFileLock_ :: FilePath -> LockMode -> IO a -> IO a
+ Plugin.GhcTags.Options: RemoveTerms :: Filter
+ Plugin.GhcTags.Options: RetainAll :: Filter
+ Plugin.GhcTags.Options: [filterOption] :: Options (f :: Type -> Type) -> Filter
+ Plugin.GhcTags.Options: [qualified] :: Options (f :: Type -> Type) -> Bool
+ Plugin.GhcTags.Options: data Filter
- Plugin.GhcTags: Options :: Bool -> Bool -> f FilePath -> Bool -> Options (f :: Type -> Type)
+ Plugin.GhcTags: Options :: Bool -> Bool -> f FilePath -> Bool -> Bool -> Filter -> Options (f :: Type -> Type)
- Plugin.GhcTags.Options: Options :: Bool -> Bool -> f FilePath -> Bool -> Options (f :: Type -> Type)
+ Plugin.GhcTags.Options: Options :: Bool -> Bool -> f FilePath -> Bool -> Bool -> Filter -> Options (f :: Type -> Type)

Files

CHANGELOG.md view
@@ -1,5 +1,84 @@ # Revision history for ghctags +## 1.0.0.0 -- 2026-10-28++**Breaking changes** (existing tags files must be regenerated):++- Kind characters for type and data families are swapped to follow the+  convention that uppercase denotes an instance: `f` = type family,+  `F` = type family instance, `d` = data family, `D` = data family instance.++- The type-signature kind character changed from `~` to `⊢`.++- Pattern synonym signatures now use the signature kind (`⊢`) instead of+  the pattern kind (`p`); unsigned pattern synonyms are unaffected.++- Relative tags file paths are now resolved from the nearest+  `cabal.project`, `cabal.project.local`, or `stack.yaml` rather than+  from the package's `*.cabal` directory.++- Extend `--debug` with wall-clock timings for lock acquisition, reading,+  parsing, generation, merging, writing, and the complete update. Cover ctags,+  streaming ctags, etags, and Template Haskell updates without double-counting+  nested streaming stages.++- Add `--no-filter` / `-F` to retain signature/definition pairs and same-named+  type/data constructors. Filtering remains enabled by default.++- Correct export flags for associated type/data family instances and type defaults.+  Tag defaults using the family name rather than the right-hand-side type, and+  keep data-instance constructors private unless their exports include them.++- Omit `file:` on default and instance methods exported through their class,+  including instance signatures, while retaining it on nested local bindings.++- Emit pattern synonym signatures with the signature kind (`⊢`) and a `type:`+  field, suppressing the corresponding pattern definition tags. Unsigned pattern+  definitions retain the pattern kind (`p`).++- Add `--qualified` to emit defining-module-qualified names alongside ordinary+  names, including Template Haskell declarations, in ctags and etags output.++- Extend `gtp-check` with duplicate declaration reports, source file and line+  checks, and comparison of ctags and etags declaration locations, preserving+  duplicate counts and resolving paths relative to each tags file.+++- Resolve relative tags paths from the nearest `cabal.project` or `stack.yaml`,+  honoring `STACK_YAML`. Warn and fall back to the package directory when no+  project configuration is found.++- Extend `gtp-check` with `--etags`/`-e`, parsed tag counts, ctags program/version+  metadata, parser diagnostics and warnings for unsorted ctags files.++- Refresh ctags metadata during Template Haskell updates, replacing stale or+  missing headers. Correct signature and type/data-family kind descriptions.++- Keep local bindings when an unrelated binding with the same name has a signature.++- Filter etags declarations before applying file/line ordering, consistently+  removing redundant definitions and type constructors in both output formats.++- Apply signature filtering to Template Haskell updates, removing redundant+  function tags even when generated declarations share the same source location.++- Preserve source filenames when Template Haskell updates an etags file+  containing tags from multiple modules.++- Recover valid tags before and after malformed records, report skipped input,+  and continue updating damaged tags files, including streaming and TH updates.++- Write tags atomically: output is staged to a private temporary file beside+  the destination and published with a rename. Concurrent readers always see a+  complete file; a failed or aborted update never corrupts the existing tags.+  The temporary file inherits the destination's permissions before publication.++- Remove duplicate tags consistently in streaming and non-streaming updates,+  including when the existing tags file is empty.++- Identify compiled modules relative to the tags directory, keeping modules with+  the same source path in different packages separate in shared tags files.+ ## 0.6.2.0 -- 2026-03-08  - `GHC-9.14` support.
README.md view
@@ -18,24 +18,102 @@ ----------------  ```-Usage: <program> [-e|--etags] [--stream] [--debug] [file_path]+Usage: <program> [-e|--etags] [--stream] [--debug] [--qualified] [-F|--no-filter] [file_path]   write tags from ghc abstract syntax tree  Available options:   -e,--etags               produce emacs etags file   --stream                 stream existing tags (ctags only)-  file_path                tags file: default tags or TAGS (when --etags is-                           specified)-  --debug                  debugging output+  file_path                tags file relative to cabal.project or stack.yaml:+                           default tags or TAGS (with --etags)+  --debug                  tag counts, file sizes, and update timings+  -q,--qualified           also generate names qualified by the defining module+  -F,--no-filter           retain both signatures and definitions ``` -It can be an absolute path or relative (to the `*.cabal` package file rather-than `cabal.project` file), for example:+The tags path can be absolute or relative to the nearest directory containing+`cabal.project`, `cabal.project.local`, or `stack.yaml`, searching upward from+the compiled package. Packages using the same project root can share one tags+file: ```--fplugin-opt=Plugin.GhcTags:../tags+-fplugin-opt=Plugin.GhcTags:tags ```-This is useful if for *cabal packages* which are located in subdirectories.+A `cabal.project.local` file is a project-root marker even without a+`cabal.project` beside it. A nearer marker takes precedence over configurations+in parent directories. For example, with `/repo/cabal.project` and+`/repo/package/cabal.project.local`, compiling that package writes to+`/repo/package/tags`. +If `STACK_YAML` points to an existing configuration file, its directory is used+instead. If no project configuration is found, the plugin prints a warning and+resolves the path relative to the nearest `*.cabal` package file. When no package+file is found either, it uses the compiler's working directory. Absolute output+paths are used unchanged.++## ● Qualified names++To generate module-qualified names alongside ordinary names, enable:++```+-fplugin-opt=Plugin.GhcTags:--qualified+```++For example, a declaration `lookup` in `Data.Map` produces both `lookup` and+`Data.Map.lookup`, pointing to the same declaration. This works with ctags,+streaming ctags, etags, and declarations generated by Template Haskell. Module+tags themselves are kept unchanged. Modules without a module header use `Main`.++The prefix identifies the module containing the declaration. It does not resolve+import aliases or re-exports: `import qualified Data.Map as M` does not add+`M.lookup`. Local bindings also receive module-prefixed names; those names+identify their containing module, rather than a valid qualified import.++Only recompiled modules are updated. Rebuild all modules after enabling or+disabling this option to update existing tags consistently.++## ● Signature and definition filtering++By default, a type signature replaces the corresponding function or pattern+synonym definition in the tags file. A data constructor also takes precedence+over a type constructor with the same name. To retain both tags, use:++```+-fplugin-opt=Plugin.GhcTags:--no-filter+```++The short form is `-fplugin-opt=Plugin.GhcTags:-F`. This applies to ctags,+streaming ctags, etags, and Template Haskell signature/definition pairs. Tags+remain sorted. Recompile all modules after changing this option to update+existing tags consistently.++## ● Update timings++Enable timing diagnostics with:++```+-fplugin-opt=Plugin.GhcTags:--debug+```++Each completed update reports monotonic wall-clock durations in milliseconds:++- `lock-wait`: opening and acquiring the tags-file lock.+- `read`: reading the existing tags file.+- `parse`: parsing its records.+- `generate`: extracting and preparing tags for the current module or splice.+- `merge`: combining old and new tags, including sorting where required.+- `write`: formatting, writing, and flushing the replacement's contents.+- `total`: elapsed time from before lock acquisition until after release,+  including temporary-file close/rename, bookkeeping, and other debug output.++Reports include the module, tags path, output mode, and whether the update came+from parsing or a Template Haskell declaration splice. Streaming ctags measures+each stage as records flow through it, excluding upstream work from downstream+phase times. It does not load the existing file into memory for timing.++Debug mode forces lazy results within their measured phase and adds measurement+overhead, especially when streaming. The phase times need not sum to `total`.+With debug disabled, timing does not read the clock or force additional results.+ ## ● Emacs support  To produce `etags` file you will need to pass the following option@@ -45,94 +123,178 @@  ## ● Editor configuration -By default each generated tags file is put next to the corresponding `*.cabal`-package file.  If you just have a repo with a cabal file in the main directory-`vim` default `tags` setting will work, if you have some modules in-subdirectories you will either need to set:+By default the generated `tags` file is put in the nearest directory containing+`cabal.project`, `cabal.project.local`, or `stack.yaml`. To let Vim or Neovim find+it while editing files in package subdirectories, enable searching parent+directories: ```-:set tags+=*/tags+:set tags+=./tags; ```-or pass an option to modify where tags are written, see below.+You can also pass an option to change where tags are written, see below.  ● Configuration: Ghc / Cabal / Stack ------------------------------------ -Configuration of this plugin requires some familiarity with `ghc` packages.-Check out-[documentation](https://downloads.haskell.org/~ghc/latest/docs/html/users_guide/packages.html#packages)-to use `-plugin-package` or `-plugin-package-id`.  In the examples below we-use `-plugin-package=ghc-tags-plugin` but specifying version-`-package=ghc-tags-plugin-0.0.0.0` (where `0.0.0.0` is the version you-installed), might work better.  You can use `ghc-pkg latest ghc-tags-plugin`-(likely with appropriate `--package-db` flag) to check which version is-available.+## ● Cabal: quick start with GHCi -## ● Ghc+To generate tags in a REPL session without installing the plugin separately or+editing any `.cabal` files, run: -```-ghc -plugin-package=ghc-tags-plugin -fplugin=Plugin.GhcTags+```sh+cabal repl TARGET \+  --build-depends=ghc-tags-plugin \+  --repl-options=-fplugin=Plugin.GhcTags \+  --repl-options=-fplugin-opt=Plugin.GhcTags:tags ``` -You might also need to pass `-package-db` in which you installed the plugin.+Replace `TARGET` with a component such as `lib:my-package` or `exe:my-program`.+Cabal builds the plugin and makes it available to that REPL session. This+command was tested with Cabal 3.16.1.0 and GHC 9.10.3 using a local plugin+checkout and this repository's project configuration. See Cabal's+[`--build-depends` documentation](https://cabal.readthedocs.io/en/stable/cabal-commands.html#cmdoption-repl-build-depends). -## ● Cabal+The plugin's dependencies are solved together with the target project's+dependencies, using that project's `index-state`, constraints, and+`allow-newer` settings.  You might need to modify `allow-newer` via+[`--allow-newer` documentation](https://cabal.readthedocs.io/en/stable/cabal-commands.html#cmdoption-allow-newer). -Install the `ghc-tags-plugin` to cabal store with:++This configures the REPL session only. For normal builds and HLS, use the+persistent configuration below.++## ● Cabal: persistent configuration++This setup enables the plugin through `cabal.project.local`, without changing+any `.cabal` files.++First, install the plugin into Cabal's store:++```sh+cabal install --lib ghc-tags-plugin --package-env=ghc-tags ```-cabal install --lib ghc-tags-plugin++If installation encounters the GHC 9.14 dependency bounds described above,+add `--allow-newer=pipes-safe:base,ghc-tags-pipes:base` to this command too.++The named `ghc-tags` environment keeps this installation separate from the+default GHC package environment. The configuration below loads the plugin+through its package database, so it does not require selecting that environment.+See Cabal's [package environment documentation](https://cabal.readthedocs.io/en/stable/cabal-commands.html#adding-libraries-to-ghc-package-environments).++Use the same GHC to install the plugin and build the target project. If the+project selects a particular compiler, pass `-w /absolute/path/to/ghc` to the+installation command as well. Reinstall the plugin when changing compilers.++With Cabal 3.16.1.0 or newer, run this in the target project to find its compiler+and the corresponding store directory:++```sh+cabal path --compiler-info ``` -In `cabal.project.local` file add [`program-options`] stanza, which enables the plugin-for all local packages:+Append `/package.db` to the reported `compiler-store-path` to obtain+`PACKAGE_DB`. For example, the resulting path might be:++```text+/home/alice/.cabal/store/ghc-9.10.3-1be3/package.db ```++Use the reported path rather than constructing it from the GHC version:+compiler ABI suffixes and custom store locations can change the directory name.+With older Cabal versions, locate the compiler's `package.db` in the Cabal store+(the store location can be queried with `cabal path --store-dir` where available).++Add the following [`program-options`] stanza to `cabal.project.local`:++```cabal program-options     ghc-options: -package-db=PACKAGE_DB                  -plugin-package=ghc-tags-plugin                  -fplugin=Plugin.GhcTags+                 -fplugin-opt=Plugin.GhcTags:tags ``` -`PACKAGE_DB` is likely to be something like-(all environment variables must be expanded):-```-${HOME}/.cabal/store/ghc-9.12.2/package.db-```-or on Windows (note the `""` syntax)-```-"C:\\Users\\USER_NAME\\AppData\\Roaming\\cabal\\store\\ghc-8.6.5\\package.db+Replace `PACKAGE_DB` with the absolute path found above. Environment variables+such as `$HOME` are not expanded in this file. If the path contains spaces,+quote the entire argument, for example `"-package-db=C:/Users/My Name/cabal/store/ghc-VERSION/package.db"`.++The stanza applies to all local packages. You can now run `cabal build` or+`cabal repl` normally. When HLS uses a Cabal cradle, restart it after changing+the configuration so it reloads the compiler options.++The default relative path shares a tags file across packages that resolve to the+same project root. To choose an absolute destination, use:++```text+-fplugin-opt=Plugin.GhcTags:/absolute/path/to/tags ``` -Note that you can also configure in this way non-local projects.  You will-likely want to pass `-fplugin-opt=Plugin.GhcTags=PATH` where `PATH` is *an-absolute* path to your `tags` file.+Relative paths in the examples above are resolved from the project configuration+directory. For example, `../tags` writes one directory above the selected+`cabal.project`, `cabal.project.local`, or `stack.yaml`. +To apply the plugin to dependencies as well, use a `package *` stanza instead+of `program-options`. This can rebuild dependencies and applies the options to+the plugin's own dependencies too; install the plugin before enabling it. -## ● Stack+## ● GHC directly -This is alternative method, which also could be modified for `cabal` (but it is-not as nice as the previous method where you don't need to modify any files-checked in a VCS).+After installing the plugin, pass its package database and enable it: -Add `ghc-tags-plugin` to  `build-depends` in your `*.cabal` files. (You should-hide it behind a cabal flag).  And add these lines to `stack.yaml` file:+```sh+ghc -package-db=PACKAGE_DB \+    -plugin-package=ghc-tags-plugin \+    -fplugin=Plugin.GhcTags Main.hs+``` +Replace `PACKAGE_DB` as described above. If multiple plugin versions are+installed, select one with `-plugin-package=ghc-tags-plugin-VERSION`, or use+`-plugin-package-id=UNIT_ID` to select a particular installed instance. Inspect+the database with:++```sh+ghc-pkg --package-db=PACKAGE_DB list ghc-tags-plugin ```-extra-deps:-  - git: https://github.com/coot/ghc-tags-plugin-    commit: a841dae7fb9c335101f7fa4187d02687d306f972 -test-project: -plugin-package=ghc-tags-plugin+See GHC's [package documentation](https://downloads.haskell.org/ghc/latest/docs/users_guide/packages.html)+and [compiler plugin documentation](https://downloads.haskell.org/ghc/latest/docs/users_guide/extending_ghc.html#using-compiler-plugins).++## ● Stack++Add `ghc-tags-plugin` to `build-depends` in the relevant components of your+`.cabal` files, optionally behind a development flag. If the plugin is not in+your snapshot, add a release compatible with the snapshot's GHC to `extra-deps`+in `stack.yaml`.++Configure the target package in `stack.yaml` under `ghc-options`:++```yaml+ghc-options:+  my-package: -plugin-package=ghc-tags-plugin               -fplugin=Plugin.GhcTags+              -fplugin-opt=Plugin.GhcTags:tags ``` +Replace `my-package` with your package name. This Stack setup uses a declared+build dependency; the Cabal setups above avoid changing `.cabal` files.+ ## ● Ghcid -If you follow the cabal configuration as above (using `stack` should work too)+For automatic tag updates while editing, use the REPL quick start with ghcid:++```sh+ghcid --command="cabal repl TARGET --build-depends=ghc-tags-plugin --repl-options=-fplugin=Plugin.GhcTags --repl-options=-fplugin-opt=Plugin.GhcTags:tags" ```-ghcid --comaand "cabal repl project"++If you have already enabled the plugin in `cabal.project.local`, use:++```sh+ghcid --command="cabal repl TARGET" ```-will update `tags` file as you modify your project. +Replace `TARGET` with the component you want ghcid to load. + ## ● Makefile  The [Makefile](https://github.com/coot/ghc-tags-plugin/blob/master/Makefile)@@ -140,6 +302,45 @@ package in a `package.db` (by default into `cabal` store).  This is mostly for development, but it could be useful in other scenarios as well. +## ● Checking and comparing tags files++The optional `gtp-check` development tool reports tag counts, ctags metadata,+parser diagnostics, sorting problems, repeated declaration locations, missing+or unreadable source files, and line numbers outside their source files:++```sh+cabal run -fgtp-check gtp-check -- tags+cabal run -fgtp-check gtp-check -- --etags TAGS+```++Source paths are resolved relative to the directory containing each tags file,+so the command can be run from another directory. Duplicate locations mean+entries with the same source file, tag name and line number; kinds and columns+are not used to distinguish them.++For a tags file copied elsewhere, pass `--source-root /path/to/original/root`+to resolve source paths from its original directory. This override applies to+both files when comparing snapshots.++To compare the declarations in two files:++```sh+cabal run -fgtp-check gtp-check -- tags --compare TAGS+cabal run -fgtp-check gtp-check -- --etags TAGS --compare tags+```++The format of the comparison file is detected automatically. Both files can+also use the same format. Comparison uses canonical source paths, names and+line numbers, and preserves duplicate counts. Differences are reported with+concrete declaration locations. Tags without numeric line addresses are+reported and excluded from line checks and comparison; a ctags `line:` field+can supply the line number for an entry addressed by a search pattern.++Each diagnostic category shows up to ten examples by default. Use+`--max-examples N` to change the limit, or `--max-examples 0` to show all.+Tags-file read or parse errors and unequal comparisons return a nonzero exit+status. Other findings are warnings.+ ● Exceptions ------------ @@ -158,25 +359,26 @@ ● Tips ------ -- If you're getting installation problems when running-  `cabal install --lib ghc-tags-plugin`; you may need to--  * remove the installed version from-    `~/.ghc/x86_64-linux-8.6.5/environments/default`-  (or whatever is your default environment)--  * unregister the installed version from cabal store (you can check what is-  installed in your store with `ghc-pkg --package=PACKAGE_DB list | grep ghc-tags`-  for the following command):+- If GHC cannot find the installed plugin, check that installation and+  compilation use the same compiler and package database: -  ```-  ghc-pkg --package-db=PACKAGE_DB unregister z-ghc-tags-plugin-z-ghc-tags-library ghc-tags-plugin+  ```sh+  ghc-pkg --package-db=PACKAGE_DB list ghc-tags-plugin   ``` +  After changing GHC versions, reinstall the plugin with the new compiler and+  update `PACKAGE_DB` in `cabal.project.local`.+ - The plugin is safe for concurrent compilation, i.e. setting `jobs: $ncpus` is   safe.  The plugin holds an exclusive (advisory) lock on a lock file.  This   will create synchronisation between threads / process which are using   the same `tags` file.++- Tag updates are written to a temporary file and renamed over the destination+  only after a successful write. Interrupted compilation (including editor+  cancellation) leaves the previous tags file intact. If tags were lost with an+  older plugin version, rebuild the affected modules with `-fforce-recomp` to+  regenerate them; an incremental build may skip those modules.  - If you are working on a larger project, it might be better to not collect all   tags in a single `tags` file, since at every compilation step one will need
+ app/GhcTags/Check.hs view
@@ -0,0 +1,174 @@+{-# LANGUAGE GADTs            #-}+{-# LANGUAGE LambdaCase       #-}+{-# LANGUAGE NamedFieldPuns   #-}+{-# LANGUAGE TypeApplications #-}++-- | Format-independent inspection of declaration locations.+module GhcTags.Check+  ( TagLocation (..)+  , Declaration (..)+  , SourceReport (..)+  , tagLocation+  , resolveTagPaths+  , declarationCounts+  , duplicateDeclarations+  , compareDeclarations+  , countSourceLines+  , checkSources+  ) where++import Control.Exception (IOException, displayException, evaluate, try)+import Control.Monad (forM)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BSC+import qualified Data.List as List+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Maybe (mapMaybe, maybeToList)+import Data.Text (Text)+import qualified Data.Text as Text+import System.Directory (canonicalizePath, makeAbsolute)+import System.FilePath (normalise, (</>))+import System.IO.Error (isDoesNotExistError)+import Text.Read (readMaybe)++import GhcTags.Tag+  ( Tag (..), TagAddress (..), TagField (..), TagFields (..)+  , TagFilePath (..), TagName (..)+  )+++data TagLocation = TagLocation+  { sourcePath :: FilePath+  , sourceLine :: Maybe Int+  , sourceName :: Text+  }+  deriving (Eq, Ord, Show)++-- | The information shared by ctags and etags. Kind, column and byte offset+-- are deliberately absent: they cannot be compared across the two formats.+data Declaration = Declaration+  { declarationPath :: FilePath+  , declarationLine :: Int+  , declarationName :: Text+  }+  deriving (Eq, Ord, Show)++data SourceReport = SourceReport+  { missingSources    :: [FilePath]+  , unreadableSources :: [(FilePath, String)]+  , invalidLines      :: [(Declaration, Int)]+    -- ^ Declaration and the number of lines in its source file.+  }+  deriving (Eq, Show)+++tagLocation :: Tag tk -> TagLocation+tagLocation tag = TagLocation+  { sourcePath = Text.unpack $ getRawFilePath $ tagFilePath tag+  , sourceLine = case tagAddr tag of+      TagLine line      -> Just line+      TagLineCol line _ -> Just line+      NoAddress         -> Nothing+      TagCommand _      -> case tagFields tag of+        TagFields fields -> case mapMaybe lineField fields of+          line : _ -> Just line+          []       -> Nothing+  , sourceName = getTagName $ tagName tag+  }+  where+    lineField TagField {fieldName, fieldValue}+      | fieldName == Text.pack "line" = readMaybe $ Text.unpack fieldValue+      | otherwise = Nothing+++-- | Resolve each distinct path once, relative to the supplied source root.+-- Canonicalization also handles symlinks.+resolveTagPaths :: FilePath -> [TagLocation] -> IO [TagLocation]+resolveTagPaths sourceRoot tags = do+    directory <- makeAbsolute sourceRoot+    paths <- forM (Map.keys $ Map.fromList [(sourcePath tag, ()) | tag <- tags]) $ \path -> do+      let absolute = directory </> path+      result <- try (canonicalizePath absolute) :: IO (Either IOException FilePath)+      -- Preserve a usable path for read diagnostics if canonicalization fails.+      pure (path, either (const $ normalise absolute) id result)+    let resolvedPaths = Map.fromList paths+    pure [tag {sourcePath = resolvedPaths Map.! sourcePath tag} | tag <- tags]+++declaration :: TagLocation -> Maybe Declaration+declaration TagLocation {sourcePath, sourceLine, sourceName} =+    (\line -> Declaration sourcePath line sourceName) <$> sourceLine+++declarationCounts :: [TagLocation] -> Map Declaration Int+declarationCounts = Map.fromListWith (+) . map (\location -> (location, 1))+                  . mapMaybe declaration+++duplicateDeclarations :: [TagLocation] -> Map Declaration Int+duplicateDeclarations = Map.filter (> 1) . declarationCounts+++-- | Multiset differences, preserving how many copies occur only on each side.+compareDeclarations :: [TagLocation]+                    -> [TagLocation]+                    -> (Map Declaration Int, Map Declaration Int)+compareDeclarations left right =+    ( leftCounts `difference` rightCounts+    , rightCounts `difference` leftCounts+    )+  where+    leftCounts  = declarationCounts left+    rightCounts = declarationCounts right+    difference  = Map.differenceWith $+      \a b -> if a > b then Just (a - b) else Nothing+++-- | Count lines without inventing a trailing empty line after a final LF.+-- CRLF files use the same count. An empty file has no valid line numbers.+countSourceLines :: BS.ByteString -> Int+countSourceLines bytes = BSC.count '\n' bytes+                       + if BS.null bytes || BSC.last bytes == '\n'+                           then 0 else 1+++-- | Read each source file once. Failed reads remain distinct from missing+-- files; line bounds can only be checked for successfully read sources.+checkSources :: [TagLocation] -> IO SourceReport+checkSources tags = do+    sources <- forM paths $ \path -> do+      result <- try @IOException (BS.readFile path >>= evaluate . countSourceLines)+      pure (path, result)+    let (missing, unreadable, lineCounts) =+          List.foldl' collect ([], [], Map.empty) sources+        badLines =+          [ (location, linesInFile)+          | location@Declaration+              { declarationPath,+                declarationLine+              } <- Map.keys $ declarationCounts tags+          , linesInFile <- maybeToList (Map.lookup declarationPath lineCounts)+          , declarationLine < 1 || declarationLine > linesInFile+          ]+    pure SourceReport+      { missingSources    = reverse missing+      , unreadableSources = reverse unreadable+      , invalidLines      = badLines+      }+  where+    paths = Map.keys $ Map.fromList [(sourcePath tag, ()) | tag <- tags]++    collect :: ([FilePath], [(FilePath, String)], Map FilePath Int)+            -> (FilePath, Either IOException Int)+            -> ([FilePath], [(FilePath, String)], Map FilePath Int)+    collect (missing, unreadable, counts) = \case+      (path, Right linesInFile)+        -> (missing, unreadable, Map.insert path linesInFile counts)++      (path, Left err)+        |  isDoesNotExistError err+        -> (path : missing, unreadable, counts)++        |  otherwise+        -> (missing, (path, displayException err) : unreadable, counts)
app/check.hs view
@@ -1,25 +1,243 @@--- | Utility program which checks the size of tags file.------ It's a like `wc` but using `lock` file, so we don't get intermediate--- results.---+{-# LANGUAGE GADTs          #-}+{-# LANGUAGE NamedFieldPuns #-}++-- | Inspect a consistent snapshot of a ctags or etags file. module Main where +import           Control.Exception (IOException, displayException, handle)+import           Control.Monad (when) import qualified Data.ByteString as BS-import qualified Data.ByteString.Char8 as BSC-import           System.FilePath-import           System.IO-import           System.Environment+import           Data.Either (partitionEithers)+import qualified Data.Map.Strict as Map+import           Data.Maybe (fromMaybe)+import qualified Data.Text as Text+import           Options.Applicative+import           System.Exit (exitFailure)+import           System.FilePath (splitFileName, takeDirectory, (</>))+import           System.IO (hPutStrLn, stderr)+import           Text.Read (readMaybe) +import qualified GhcTags.CTag as CTag+import qualified GhcTags.ETag as ETag+import qualified GhcTags.Check as Check+import           GhcTags.Tag (tagName)+ import           Plugin.GhcTags.FileLock  +data Options = Options+  { etags       :: Bool+  , filePath    :: FilePath+  , compareFile :: Maybe FilePath+  , sourceRoot  :: Maybe FilePath+  , maxExamples :: Int+  }+++optionsParser :: Parser Options+optionsParser = Options+    <$> switch (  long "etags"+               <> short 'e'+               <> help "read an emacs etags file")+    <*> strArgument (  metavar "FILE"+                    <> help "tags file to inspect"+                    <> showDefault+                    <> value "tags"+                    )+    <*> optional (strOption+          ( long "compare"+         <> metavar "FILE"+         <> help "compare declaration locations with FILE (ctags/etags detected automatically)"))+    <*> optional (strOption+          ( long "source-root"+         <> metavar "DIR"+         <> help "resolve source paths in both files relative to DIR (default: each tags file's directory)"))+    <*> option (eitherReader readLimit)+          ( long "max-examples"+         <> metavar "N"+         <> value 10+         <> showDefault+         <> help "maximum examples per diagnostic category; 0 shows all")+  where+    readLimit inputText = case readMaybe inputText of+      Just n | n >= 0 -> Right n+      _ -> Left "expected a non-negative integer"+++data Format = CTags | ETags+  deriving (Eq, Show)++data ParsedFile = ParsedFile+  { parsedPath :: FilePath+  , parsedLocations :: [Check.TagLocation]+  }++ main :: IO () main = do-    file :_ <- getArgs-    withFileLock False (lockFile file) ExclusiveLock $ \_h -> do-      numOfLines <- length . BSC.lines <$> BS.readFile file-      putStrLn (show numOfLines)+    options <- execParser $+      info (optionsParser <**> helper)+        (fullDesc <> progDesc "Parse a tags file and report metadata, counts and diagnostics")+    handle reportIOException (checkFiles options)+++checkFiles :: Options -> IO ()+checkFiles Options { etags,+                     filePath,+                     compareFile,+                     sourceRoot,+                     maxExamples }+    = do+    primary <- inspectFile maxExamples sourceRoot (Just $ if etags then ETags else CTags) filePath+    case compareFile of+      Nothing -> pure ()+      Just otherPath -> do+        other <- inspectFile maxExamples sourceRoot Nothing otherPath+        reportComparison maxExamples primary other+++inspectFile :: Int -> Maybe FilePath -> Maybe Format -> FilePath -> IO ParsedFile+inspectFile limit sourceRootOverride requestedFormat path = do+    bytes <- withFileLock_ (lockFile path) SharedLock (BS.readFile path)+    let format = maybe (detectFormat bytes) id requestedFormat+        warning message = reportWarning (path ++ ": " ++ message)++        parseResult :: Either String ([String], a) -> IO a+        parseResult result = case result of+          Left err -> reportError (path ++ ": " ++ err)+          Right (warnings, parsed) -> do+            reportExamples limit (path ++ ": parse warnings") [path ++ ": " ++ message | message <- warnings]+            pure parsed+    putStrLn ("File: " ++ path ++ if format == ETags then " (etags)" else " (ctags)")+    locations <- case format of+      ETags -> do+        tags <- ETag.parseTagsFileRecovering bytes >>= parseResult+        pure $ map Check.tagLocation tags+      CTags -> do+        parsed <- CTag.parseTagsFileRecovering bytes >>= parseResult+        let (headers, tags) = partitionEithers parsed+            adjacent = zip tags (drop 1 tags)+        mapM_ reportHeader headers+        if any (\(a, b) -> tagName a > tagName b) adjacent+          then warning "ctags are not sorted by name"+          else when (any (\(a, b) -> CTag.compareTags a b == GT) adjacent) $+            warning "ctags names are sorted, but secondary ordering differs from ghc-tags-plugin"+        pure $ map Check.tagLocation tags+    resolved <- Check.resolveTagPaths (fromMaybe (takeDirectory path) sourceRootOverride) locations+    let duplicates = Check.duplicateDeclarations resolved+        sourceFiles = Map.fromList [(Check.sourcePath tag, ()) | tag <- resolved]+        unlocated = length $ filter ((== Nothing) . Check.sourceLine) resolved+    putStrLn ("Tags: " ++ show (length resolved))+    putStrLn ("Source files: " ++ show (Map.size sourceFiles))+    putStrLn ("Declaration locations: " ++ show (Map.size $ Check.declarationCounts resolved))+    putStrLn ("Duplicate declaration locations: " ++ show (Map.size duplicates)+           ++ " (" ++ show (sum $ map (subtract 1) $ Map.elems duplicates) ++ " extra tags)")+    reportExamples limit (path ++ ": duplicate locations")+      [ path ++ ": duplicate declaration location: " ++ displayDeclaration location+        ++ " (" ++ show count ++ " tags)"+      | (location, count) <- Map.toAscList duplicates+      ]+    when (unlocated > 0) $ warning $+      show unlocated ++ " tags have no numeric line address; excluded from line checks and declaration comparison"+    sources <- Check.checkSources resolved+    reportSources limit path sources+    pure ParsedFile {parsedPath = path, parsedLocations = resolved}+++detectFormat :: BS.ByteString -> Format+detectFormat bytes = case BS.uncons $ BS.dropWhile (`elem` [9, 10, 13, 32]) bytes of+    Just (12, _) -> ETags+    _            -> CTags+++reportSources :: Int -> FilePath -> Check.SourceReport -> IO ()+reportSources limit path Check.SourceReport+  {Check.missingSources, Check.unreadableSources, Check.invalidLines} = do+    putStrLn ("Missing source files: " ++ show (length missingSources))+    putStrLn ("Unreadable source files: " ++ show (length unreadableSources))+    putStrLn ("Invalid line numbers: " ++ show (length invalidLines))+    reportExamples limit (path ++ ": missing files")+      [path ++ ": source file does not exist: " ++ source | source <- missingSources]+    reportExamples limit (path ++ ": unreadable files")+      [path ++ ": cannot read source file " ++ source ++ ": " ++ err | (source, err) <- unreadableSources]+    reportExamples limit (path ++ ": invalid line numbers")+      [ path ++ ": invalid line number: " ++ displayDeclaration location+        ++ " (source has " ++ show count ++ " lines)"+      | (location, count) <- invalidLines+      ]+++reportComparison :: Int -> ParsedFile -> ParsedFile -> IO ()+reportComparison limit left right = do+    let (onlyLeft, onlyRight) = Check.compareDeclarations (parsedLocations left) (parsedLocations right)+        reportOnly file difference = do+          putStrLn ("Only in " ++ file ++ ": " ++ show (sum $ Map.elems difference)+                 ++ " tags at " ++ show (Map.size difference) ++ " declaration locations")+          reportExamples limit ("only in " ++ file)+            [ "only in " ++ file ++ ": " ++ displayDeclaration location+              ++ " (" ++ show count ++ " tags)"+            | (location, count) <- Map.toAscList difference+            ]+    putStrLn ("Comparison: " ++ parsedPath left ++ " vs " ++ parsedPath right)+    reportOnly (parsedPath left) onlyLeft+    reportOnly (parsedPath right) onlyRight+    if Map.null onlyLeft && Map.null onlyRight+      then putStrLn "Comparable declaration locations match (including duplicate counts)."+      else exitFailure+++displayDeclaration :: Check.Declaration -> String+displayDeclaration Check.Declaration {Check.declarationPath, Check.declarationLine, Check.declarationName} =+    declarationPath ++ ":" ++ show declarationLine ++ ": " ++ show (Text.unpack declarationName)+++reportExamples :: Int -> String -> [String] -> IO ()+reportExamples limit category examples = do+    let shown = if limit == 0 then examples else take limit examples+        remaining = length examples - length shown+    mapM_ reportWarning shown+    when (remaining > 0) $ reportWarning $+      category ++ ": " ++ show remaining ++ " more (use --max-examples 0 to show all)"+++reportHeader :: CTag.Header -> IO ()+reportHeader CTag.Header { CTag.headerType,+                           CTag.headerArg,+                           CTag.headerComment } =+    case headerType of+      CTag.ProgramName ->+        putStrLn (withComment $ "Program: " ++ Text.unpack headerArg)+      CTag.ProgramVersion ->+        putStrLn (withComment $ "Version: " ++ Text.unpack headerArg)+      CTag.FileFormat ->+        putStrLn (withComment $ "FileFormat: " ++ show headerArg)+      CTag.FileSorted ->+        putStrLn (withComment $ "FileSorted: " ++ show headerArg)+      _ -> pure ()+   where-    lockFile file = case splitFileName file of-      (dir, name) -> dir </> "." ++ name ++ ".lock"+    withComment :: String -> String+    withComment a+      | not (Text.null headerComment)+      = a ++ " (" ++  Text.unpack headerComment ++ ")"+      | otherwise+      = a+++reportWarning :: String -> IO ()+reportWarning message = hPutStrLn stderr ("Warning: " ++ message)+++reportError :: String -> IO a+reportError message = do+    hPutStrLn stderr ("Error: " ++ message)+    exitFailure+++reportIOException :: IOException -> IO ()+reportIOException = reportError . displayException+++lockFile :: FilePath -> FilePath+lockFile file = case splitFileName file of+    (dir, name) -> dir </> "." ++ name ++ ".lock"
ghc-tags-plugin.cabal view
@@ -1,6 +1,6 @@ cabal-version:       3.0 name:                ghc-tags-plugin-version:             0.6.2.0+version:             1.0.0.0 synopsis:            A compiler plugin which generates tags file from GHC parsed syntax tree. description:   __etags__ format.  For a standalone `ghc-tags` command look for or@@ -12,10 +12,10 @@ license-file:        LICENSE author:              Marcin Szamotulski maintainer:          coot@coot.me-copyright:           (c) 2020-2025, Marcin Szamotulski+copyright:           (c) 2020-2026, Marcin Szamotulski category:            Development stability:           alpha-extra-source-files:  CHANGELOG.md+extra-doc-files:     CHANGELOG.md                      README.md homepage:            https://github.com/coot/ghc-tags-plugin#readme bug-reports:         https://github.com/coot/ghc-tags-plugin/issues@@ -46,39 +46,87 @@   exposed-modules:     Plugin.GhcTags                        Plugin.GhcTags.Options                        Plugin.GhcTags.FileLock-  other-modules:       Plugin.GhcTags.CTag+  other-modules:       Plugin.GhcTags.AtomicFile+                       Plugin.GhcTags.CTag+                       Plugin.GhcTags.Project+                       Plugin.GhcTags.Timing                        Paths_ghc_tags_plugin   autogen-modules:     Paths_ghc_tags_plugin-  build-depends:       base              >=4.12 && < 4.23,-                       bytestring        >=0.10 && < 0.13,+  build-depends:       base              >=4.12 && <4.23,+                       bytestring        >=0.10 && <0.13,+                       containers        >=0.6.0.1 && <0.9,+                       deepseq           >=1.4 && <1.6,                        directory        ^>=1.3,-                       filepath         ^>=1.4 || ^>= 1.5,+                       filepath         ^>=1.4 || ^>=1.5,                        ghc               >=8.4 && <10,                        lukko            ^>=0.1,                        mtl               >=2.2 && <2.4,                        optparse-applicative-                                         >=0.15.1 && < 0.19,+                                         >=0.15.1 && <0.20,                        pipes            ^>=4.3,                        pipes-bytestring ^>=2.1,                        pipes-safe       ^>=2.3,                        text              >=1.2 && <2.2, -                       ghc-tags-core   ^>=0.6,-                       ghc-tags-pipes  ^>=0.1.1+                       ghc-tags-core   ^>=1.0.0.0,+                       ghc-tags-pipes  ^>=0.2.0.1   default-language:    Haskell2010   executable gtp-check+  import:              warnings   if flag(gtp-check)     buildable:         True   else     buildable:         False   hs-source-dirs:      app   main-is:             check.hs+  other-modules:       GhcTags.Check   default-language:    Haskell2010-  build-depends:       base+  build-depends:       base >=4.12 && <4.23                      , bytestring+                     , containers                      , directory                      , filepath+                     , optparse-applicative+                     , text +                     , ghc-tags-core                      , ghc-tags-plugin+++test-suite tests+  import:              warnings+  type:                exitcode-stdio-1.0+  hs-source-dirs:      test lib app+  main-is:             main.hs+  other-modules:       Plugin.GhcTags.AtomicFile+                       Plugin.GhcTags.Options+                       Plugin.GhcTags.Project+                       Plugin.GhcTags.Timing+                       GhcTags.Check+                       Test.AtomicFile+                       Test.Check+                       Test.Ghc+                       Test.Ghc.Generators+                       Test.Options+                       Test.ProjectFile+                       Test.Timing+  build-depends:       base >=4.12 && <4.23,+                       bytestring,+                       containers,+                       deepseq,+                       directory,+                       filepath,+                       ghc,+                       ghc-boot-th,+                       ghc-tags-core,+                       optparse-applicative,+                       pipes,+                       quickcheck-instances,+                       tasty,+                       tasty-hunit,+                       tasty-quickcheck,+                       temporary,+                       text+  default-language:    Haskell2010
lib/Plugin/GhcTags.hs view
@@ -1,6 +1,7 @@ {-# LANGUAGE CPP                 #-} {-# LANGUAGE BangPatterns        #-} {-# LANGUAGE GADTs               #-}+{-# LANGUAGE LambdaCase          #-} {-# LANGUAGE NamedFieldPuns      #-} {-# LANGUAGE RankNTypes          #-} {-# LANGUAGE ScopedTypeVariables #-}@@ -27,9 +28,10 @@ import qualified Data.Text.Encoding as Text import           Data.Functor.Identity (Identity (..)) import           Data.List (sortBy)-import           Data.Either (partitionEithers, rights)-import           Data.Foldable (traverse_)-import           Data.Maybe (mapMaybe)+import           Data.Either (rights)+import           Data.Maybe (fromMaybe, mapMaybe)+import qualified System.Directory as Directory+import           System.Environment (lookupEnv) #if MIN_VERSION_filepath(1,4,100) import qualified System.OsPath as OsPath import           System.Directory.OsPath@@ -67,13 +69,20 @@                                   , metaRequestT                                   ) import           GHC.Driver.Hooks (Hooks (..))-import           GHC.Unit.Types   (Module)+import           GHC.Unit.Types   (Module, moduleName)+#if MIN_VERSION_GLASGOW_HASKELL(9,6,0,0)+import           Language.Haskell.Syntax.Module.Name (moduleNameString)+#else+import           GHC.Unit.Module.Name (moduleNameString)+#endif import           GHC.Unit.Module.Location   (ModLocation (..))-import           GHC.Tc.Types (TcM)+import           GHC.Tc.Types (TcM, tcg_mod)+import           GHC.Tc.Utils.Monad (getGblEnv) import           GHC.Tc.Gen.Splice (defaultRunMeta) import           GHC.Types.SrcLoc (Located) import qualified GHC.Types.SrcLoc as GHC (SrcSpan (..), getLoc, srcSpanFile) import           GHC.Driver.Session (DynFlags)+import           GHC.Driver.Session as GHC (workingDirectory)  import           GHC.Hs (GhcPs, GhcTc, HsModule (..), LHsDecl, LHsExpr) import           GHC.Utils.Outputable (($+$), ($$))@@ -88,7 +97,10 @@ import qualified GhcTags.ETag as ETag  import           Plugin.GhcTags.Options+import           Plugin.GhcTags.AtomicFile import           Plugin.GhcTags.FileLock+import           Plugin.GhcTags.Timing+import           Plugin.GhcTags.Project (TagsAtRoot (..), resolveTagsFile) import qualified Plugin.GhcTags.CTag as CTag  @@ -160,11 +172,9 @@      hsParsedModule <$       case runOptionParser options of-        Success opts@Options { filePath = Identity tagsFile-                             , debug-                             } ->--           liftIO $ do+        Success parsedOptions ->+           liftIO $ withResolvedOptions dynFlags (Just ms_mod) parsedOptions $ \opts@Options+             { filePath = Identity tagsFile, debug } -> do #if MIN_VERSION_filepath(1,4,100)             tagsPath <- OsPath.encodeUtf tagsFile #endif@@ -179,7 +189,7 @@                 -- file.  This is needed when `cabal` compiles in parallel.                 -- We take the lock on the copy, otherwise the lock would be removed when                 -- we move the file.-                withFileLock debug (lockFilePath tagsFile) ExclusiveLock $ \_ -> do+                withUpdateTimings opts dynFlags ms_mod "parsed" $ \timings -> do                     mbInSize <-                       if debug                         then Just <$> getFileSize@@ -190,7 +200,7 @@ #endif                                       `catch` \(_ :: IOException) -> pure 0                         else pure Nothing-                    updateTags opts moduleSummary hpm_module+                    updateTags timings opts moduleSummary hpm_module                     when debug $ do                       let Just inSize = mbInSize                       outSize <- getFileSize@@ -224,31 +234,36 @@ data MessageType =       ReadException     | ParserException+    | ParseWarning     | WriteException     | UnhandledException     | OptionParserFailure     | DebugMessage     | SizeWarning+    | ProjectRootWarning   instance Show MessageType where     show ReadException       = "read error"     show ParserException     = "tags parser error"+    show ParseWarning        = "tags recovered after parse errors"     show WriteException      = "write error"     show UnhandledException  = "unhandled error"     show OptionParserFailure = "plugin options parser error"     show SizeWarning         = "tags file shrinked"+    show ProjectRootWarning  = "project configuration not found"     show DebugMessage        = ""    -- | Extract tags from a module and update tags file ---updateTags :: Options Identity+updateTags :: Timings+           -> Options Identity            -> ModSummary            -> Located GhcPsModule            -> IO ()-updateTags Options { etags, stream, filePath = Identity tagsFile, debug }+updateTags timings opts@Options { etags, stream, filePath = Identity tagsFile, debug }            ModSummary {ms_mod, ms_location, ms_hspp_opts = dynFlags}            lmodule = do     case (etags, stream) of@@ -274,8 +289,6 @@ #else                           tagsFile #endif-      let destFile = case FilePath.splitFileName tagsFile of-            (dir, name) -> dir FilePath.</> "." ++ name        mbInSize <-         if debug@@ -291,8 +304,14 @@               else pure (Just 0)           else pure Nothing -      withFile destFile WriteMode  $ \writeHandle ->-        withFile tagsFile ReadWriteMode $ \readHandle -> do+      withAtomicFile tagsFile $ \writeHandle ->+        handle (\(err :: ParsingError) -> do+          printMessageDoc dynFlags ParserException (Just ms_mod) (displayException err)+          pure False) $+        let withInput k+              | tagsFileExists = withFile tagsFile ReadMode (k . Just)+              | otherwise = k Nothing+        in withInput $ \readHandle -> do #if MIN_VERSION_filepath(1,4,100)           cwd <- getCurrentDirectory #else@@ -306,12 +325,13 @@           tagsDir <- rawFilePathFromBS . BSC.pack <$> canonicalizePath (fst $ FilePath.splitFileName tagsFile) #endif           case ml_hs_file ms_location of-            Nothing         -> pure ()+            Nothing         -> pure False             Just sourcePath -> do               let sourcePathBS = rawFilePathFromBS $ Text.encodeUtf8 (Text.pack sourcePath)-                  -- path of the compiled module; it is relative to the cabal file,-                  -- not the project.+                  -- Identify the module relative to the tags file, just like+                  -- the generated tags.                   modulePath =+                    fixFilePath cwd tagsDir $                     case GHC.getLoc lmodule of                       GHC.RealSrcSpan rss _ ->                           rawFilePathFromBS@@ -319,73 +339,63 @@                         . GHC.srcSpanFile                         $ rss                       GHC.UnhelpfulSpan {} ->-                        fixFilePath cwd tagsDir sourcePathBS+                        sourcePathBS                   -- text parser                   producer :: Pipes.Producer ByteString (SafeT IO) ()-                  producer-                    | tagsFileExists =-                        void (Pipes.BS.fromHandle readHandle)+                  producer = timedProducer timings ReadTags $ case readHandle of+                    Just inputHandle ->+                        void (Pipes.BS.fromHandle inputHandle)                         `Pipes.Safe.catchP` \(e :: IOException) ->                           Pipes.lift $ Pipes.liftIO $-                            -- don't re-throw; this would kill `ghc`, error-                            -- loudly and continue.-                            putDocLn dynFlags (messageDoc ReadException (Just ms_mod) (displayException e))-                    | otherwise      = pure ()+                            -- Abort the replacement rather than publish partial tags.+                            putDocLn dynFlags (messageDoc ReadException (Just ms_mod) (displayException e)) >> throwIO e+                    Nothing -> pure () -                  -- tags pipe-                  pipe :: Pipes.Effect (StateT Int (StateT [CTag] (SafeT IO))) ()-                  pipe =-                    Pipes.for-                      (Pipes.hoist Pipes.lift $ Pipes.hoist Pipes.lift (tagParser (either (const Nothing) Just <$> CTag.parseTagLine) producer)+                  parsed :: Pipes.Producer CTag (StateT Int (SafeT IO)) ()+                  parsed = timedProducer timings ParseTags $ Pipes.for+                    (Pipes.hoist Pipes.lift+                      (tagParserWithWarnings+                        (Pipes.liftIO . printMessageDoc dynFlags ParseWarning (Just ms_mod))+                        (either (const Nothing) Just <$> CTag.parseTagLine) producer)+                      `Pipes.Safe.catchP` \(e :: IOException) ->+                        Pipes.liftIO $ do+                          putDocLn dynFlags (messageDoc ParserException (Just ms_mod) (displayException e))+                          throwIO e)+                    (\tag -> modify' succ >> Pipes.yield tag)++                  pipe :: [CTag] -> Pipes.Effect (StateT Int (SafeT IO)) ()+                  pipe newTags = Pipes.for+                    (timedProducer timings MergeTags $ combineTagsStream CTag.compareTags modulePath newTags parsed)+                    (\tag ->+                      Pipes.liftIO (timed timings WriteTags $ BB.hPutBuilder writeHandle (CTag.formatTag tag))                         `Pipes.Safe.catchP` \(e :: IOException) ->-                          Pipes.lift $ Pipes.liftIO $-                            -- don't re-throw; this would kill `ghc`, error-                            -- loudly and continue.-                            putDocLn dynFlags $ messageDoc ParserException (Just ms_mod) (displayException e)-                      )-                      -- merge tags-                      (\tag -> do-                        -- update tags counter-                        modify' succ-                        Pipes.hoist Pipes.lift $-                            runCombineTagsPipe writeHandle-                              CTag.compareTags-                              CTag.formatTag-                              modulePath-                              tag-                          `Pipes.Safe.catchP` \(e :: IOException) ->-                            Pipes.lift $ Pipes.liftIO $-                              -- don't re-throw; this would kill `ghc`, error-                              -- loudly and continue.-                              putDocLn dynFlags $ messageDoc WriteException (Just ms_mod) (displayException e)-                      )+                          Pipes.liftIO $ do+                            putDocLn dynFlags (messageDoc WriteException (Just ms_mod) (displayException e))+                            throwIO e)                let tags :: [CTag]                   tags = map (fixTagFilePath cwd tagsDir)                                               -- fix file names-                       . filterAdjacentTags-                       . sortBy compareTags   -- sort+                       . prepareTagsWithOptions opts CTag.compareTags+                                              -- filter and sort+                       . qualifyTags opts ms_mod                        . mapMaybe (ghcTagToTag SingCTag dynFlags)                                               -- translate 'GhcTag' to 'Tag'-                       . getGhcTags           -- generate 'GhcTag's+                       . getGhcTags (filterOption opts)+                                              -- generate 'GhcTag's                        $ lmodule +              void $ timedNF timings GenerateTags (pure tags)+               -- Write header-              BSL.hPut writeHandle (BB.toLazyByteString (foldMap CTag.formatHeader CTag.headers))+              timed timings WriteTags $ BSL.hPut writeHandle (BB.toLazyByteString (foldMap CTag.formatHeader (CTag.headers opts)))               -- update tags file / run 'pipe'-              (parsedTags, tags') <- Pipes.Safe.runSafeT $ runStateT (execStateT (Pipes.runEffect pipe) 0) tags-              -- write the remaining tags'-              traverse_ (BSL.hPut writeHandle . BB.toLazyByteString . CTag.formatTag) tags'+              parsedTags <- Pipes.Safe.runSafeT $ execStateT (Pipes.runEffect (pipe tags)) 0 -              hFlush writeHandle+              timed timings WriteTags (hFlush writeHandle)                when debug $ do-                outSize <- getFileSize-#if MIN_VERSION_filepath(1,4,100)-                          tagsPath-#else-                          tagsFile-#endif+                outSize <- hFileSize writeHandle                 let Just inSize = mbInSize                 printMessageDoc dynFlags DebugMessage (Just ms_mod)                   (concat [ "path: "@@ -393,25 +403,13 @@                           , " parsed: "                           , show parsedTags                           , " found: "-                          , show (length tags + length tags')+                          , show (length tags)                           , " in-size: "                           , show inSize                           , " out-size: "                           , show outSize                           ])-      -#if MIN_VERSION_filepath(1,4,100)-      destPath <- OsPath.encodeUtf destFile-      destFileExists <- doesFileExist destPath-#else-      destFileExists <- doesFileExist destFile-#endif-      when destFileExists $-#if MIN_VERSION_filepath(1,4,100)-        renameFile destPath tagsPath-#else-        renameFile destFile tagsFile-#endif+              pure True       --@@ -439,10 +437,10 @@ #endif               else pure (Just 0)           else pure Nothing-      !tagsContent <- if tagsFileExists+      !tagsContent <- timed timings ReadTags $ if tagsFileExists                         then BS.readFile tagsFile                         else return mempty-      withFile tagsFile WriteMode $ \writeHandle -> do+      withAtomicFile tagsFile $ \writeHandle -> do #if MIN_VERSION_filepath(1,4,100)         cwd <- getCurrentDirectory #else@@ -456,12 +454,13 @@         tagsDir <- rawFilePathFromBS . BSC.pack <$> canonicalizePath (fst $ FilePath.splitFileName tagsFile) #endif         case ml_hs_file ms_location of-          Nothing         -> pure ()+          Nothing         -> pure False           Just sourcePath -> do             let sourcePathBS = rawFilePathFromBS $ Text.encodeUtf8 (Text.pack sourcePath)-                -- path of the compiled module; it is relative to the cabal file,-                -- not the project.+                -- Identify the module relative to the tags file, just like+                -- the generated tags.                 modulePath =+                  fixFilePath cwd tagsDir $                   case GHC.getLoc lmodule of                     GHC.RealSrcSpan rss _ ->                         rawFilePathFromBS@@ -469,43 +468,46 @@                       . GHC.srcSpanFile                       $ rss                     GHC.UnhelpfulSpan {} ->-                      fixFilePath cwd tagsDir sourcePathBS+                      sourcePathBS -            pres <- try @IOException $ CTag.parseTagsFile tagsContent+            pres <- try @IOException $ timedNF timings ParseTags $ CTag.parseTagsFileRecovering tagsContent             case pres of               Left err   ->-                putDocLn dynFlags $ messageDoc ParserException (Just ms_mod) (displayException err)+                putDocLn dynFlags (messageDoc ParserException (Just ms_mod) (displayException err)) >> pure False                Right (Left err) ->-                printMessageDoc dynFlags ParserException (Just ms_mod) err+                printMessageDoc dynFlags ParserException (Just ms_mod) err >> pure False -              Right (Right parsed) -> do+              Right (Right (warnings, parsed)) -> do+                mapM_ (printMessageDoc dynFlags ParseWarning (Just ms_mod)) warnings                 let parsedTags = rights parsed                       tags :: [CTag]                     tags = map (fixTagFilePath cwd tagsDir)                                                 -- fix file names-                         . filterAdjacentTags-                         . sortBy compareTags   -- sort+                         . prepareTagsWithOptions opts CTag.compareTags+                                                -- filter and sort+                         . qualifyTags opts ms_mod                          . mapMaybe (ghcTagToTag SingCTag dynFlags)                                                 -- translate 'GhcTag' to 'Tag'-                         . getGhcTags           -- generate 'GhcTag's+                         . getGhcTags (filterOption opts)+                                                -- generate 'GhcTag's                          $ lmodule                      combined :: [CTag]                     combined = combineTags CTag.compareTags modulePath tags parsedTags -                BB.hPutBuilder writeHandle-                          (    foldMap CTag.formatHeader CTag.headers+                void $ timedNF timings GenerateTags (pure tags)+                void $ timedNF timings MergeTags (pure combined)++                timed timings WriteTags $ BB.hPutBuilder writeHandle+                          (    foldMap CTag.formatHeader (CTag.headers opts)                             <> foldMap CTag.formatTag combined                           )                   when debug $ do-#if MIN_VERSION_filepath(1,4,100)-                  outSize <- getFileSize tagsPath-#else-                  outSize <- getFileSize tagsFile-#endif+                  timed timings WriteTags (hFlush writeHandle)+                  outSize <- hFileSize writeHandle                   let Just inSize = mbInSize                   printMessageDoc dynFlags DebugMessage (Just ms_mod)                     (concat [ "parsed: "@@ -517,6 +519,7 @@                             , " out-size: "                             , show outSize                             ])+                pure True       --@@ -544,10 +547,10 @@ #endif               else pure (Just 0)           else pure Nothing-      !tagsContent <- if tagsFileExists+      !tagsContent <- timed timings ReadTags $ if tagsFileExists                         then BS.readFile tagsFile                         else return mempty-      withFile tagsFile WriteMode $ \writeHandle -> do+      withAtomicFile tagsFile $ \writeHandle -> do #if MIN_VERSION_filepath(1,4,100)           cwd <- getCurrentDirectory #else@@ -562,20 +565,22 @@ #endif            case ml_hs_file ms_location of-            Nothing         -> pure ()+            Nothing         -> pure False             Just sourcePath -> do-              pres <- try @IOException $ ETag.parseTagsFile tagsContent+              pres <- try @IOException $ timedNF timings ParseTags $ ETag.parseTagsFileRecovering tagsContent               case pres of-                Left err   ->-                  putDocLn dynFlags $ messageDoc ParserException (Just ms_mod) (displayException err)+                Left err ->+                  False <$ putDocLn dynFlags (messageDoc ParserException (Just ms_mod) (displayException err)) -                Right (Left err) ->-                  printMessageDoc dynFlags ParserException (Just ms_mod) err+                Right (Left err) -> do+                  False <$ printMessageDoc dynFlags ParserException (Just ms_mod) err -                Right (Right parsedTags) -> do+                Right (Right (warnings, parsedTags)) -> do+                  mapM_ (printMessageDoc dynFlags ParseWarning (Just ms_mod)) warnings                   let sourcePathBS = rawFilePathFromBS                                    $ Text.encodeUtf8 (Text.pack sourcePath)                       modulePath =+                        fixFilePath cwd tagsDir $                         case GHC.getLoc lmodule of                           GHC.RealSrcSpan rss _ ->                               rawFilePathFromBS@@ -583,27 +588,27 @@                             . GHC.srcSpanFile                             $ rss                           GHC.UnhelpfulSpan {} ->-                            fixFilePath cwd tagsDir sourcePathBS+                            sourcePathBS                        tags :: [ETag]-                      tags = filterAdjacentTags-                           . sortBy ETag.compareTags+                      tags = prepareTagsWithOptions opts ETag.compareTags                            . map (fixTagFilePath cwd tagsDir)+                           . qualifyTags opts ms_mod                            . mapMaybe (ghcTagToTag SingETag dynFlags)-                           . getGhcTags+                           . getGhcTags (filterOption opts)                            $ lmodule                        combined :: [ETag]                       combined = combineTags ETag.compareTags modulePath tags parsedTags -                  BB.hPutBuilder writeHandle (ETag.formatETagsFile combined)+                  void $ timedNF timings GenerateTags (pure tags)+                  void $ timedNF timings MergeTags (pure combined) +                  timed timings WriteTags $ BB.hPutBuilder writeHandle (ETag.formatETagsFile combined)+                   when debug $ do-#if MIN_VERSION_filepath(1,4,100)-                    outSize <- getFileSize tagsPath-#else-                    outSize <- getFileSize tagsFile-#endif+                    timed timings WriteTags (hFlush writeHandle)+                    outSize <- hFileSize writeHandle                     let Just inSize = mbInSize                     printMessageDoc dynFlags DebugMessage (Just ms_mod)                       (concat [ "parsed: "@@ -615,46 +620,7 @@                               , " out-size: "                               , show outSize                               ])----- | Filter adjacent tags.----filterAdjacentTags :: [Tag tk] -> [Tag tk]-filterAdjacentTags tags =-    foldr-      (\(mprev, c, mnext) acc ->-          case (mprev, mnext) of-            -- filter out terms preceded by a type signature-            (Just p, _)  | tagName p == tagName c-                         , TkTypeSignature <- tagKind p-                         , k <- tagKind c-                         , k == TkTerm-                        || k == TkFunction-                        ->     acc--            -- filter out type constructors followed by a data constructor-            (_, Just n)  | tagName c == tagName n-                         , TkTypeConstructor <- tagKind c-                         , k <- tagKind n-                         , k == TkDataConstructor-                        || k == TkGADTConstructor-                        ->     acc--            _           -> c : acc--      )-      []-      (zip3 tags' tags tags'')-  where-    -- previous-    tags' = case tags of-      [] -> []-      _  -> Nothing : map Just (init tags)--    -- next-    tags'' = case tags of-      []   -> []-      _:ts -> map Just ts ++ [Nothing]+                  pure True   --@@ -672,19 +638,18 @@ ghcTagsMetaHook :: [CommandLineOption] -> DynFlags -> MetaHook TcM ghcTagsMetaHook options dynFlags request expr =     case runOptionParser options of-      Success Options { filePath = Identity tagsFile-                      , etags-                      , debug-                      } -> do+      Success parsedOptions -> do -        withMetaD defaultRunMeta request expr $ \decls ->-          liftIO $+        withMetaD defaultRunMeta request expr $ \decls -> do+          mod_ <- tcg_mod <$> getGblEnv+          liftIO $ withResolvedOptions dynFlags Nothing parsedOptions $ \opts@Options+            { filePath = Identity tagsFile, etags } ->             handle (\ioerr -> do                      putDocLn dynFlags                              (messageDoc UnhandledException Nothing                                (displayException ioerr))                      throwIO (GhcTagsDynFlagsPluginIOException ioerr)) $-            withFileLock debug (lockFilePath tagsFile) ExclusiveLock $ \_ -> do+            withUpdateTimings opts dynFlags mod_ "template-haskell" $ \timings -> do #if MIN_VERSION_filepath(1,4,100)             cwd <- getCurrentDirectory #else@@ -696,43 +661,57 @@ #else             tagsDir <- rawFilePathFromBS . BSC.pack <$> canonicalizePath (fst $ FilePath.splitFileName tagsFile) #endif-            tagsContent <- BSC.readFile tagsFile+            tagsContent <- timed timings ReadTags $ BSC.readFile tagsFile+            let newTags :: SingTagKind tk -> [Tag tk]+                newTags sing = qualifyTags opts mod_+                             $ mapMaybe (fmap (fixTagFilePath cwd tagsDir)+                                         . ghcTagToTag sing dynFlags)+                             $ hsDeclsToGhcTags (filterOption opts) Nothing decls             if etags               then do-                pr <- ETag.parseTagsFile tagsContent+                pr <- timedNF timings ParseTags $ ETag.parseTagsFileRecovering tagsContent                 case pr of                   Left err ->                     printMessageDoc dynFlags ParserException Nothing err -                  Right tags -> do-                    let tags' :: [ETag]+                  Right (warnings, tags) -> do+                    mapM_ (printMessageDoc dynFlags ParseWarning Nothing) warnings+                    let generated = prepareTagsWithOptions opts ETag.compareTags $ newTags SingETag+                        tags' :: [ETag]                         tags' = sortBy ETag.compareTags $                                   tags                                   ++-                                  (fmap (fixTagFilePath cwd tagsDir)-                                  . ghcTagToTag SingETag dynFlags)-                                    `mapMaybe`-                                     hsDeclsToGhcTags Nothing decls-                    BSL.writeFile tagsFile (BB.toLazyByteString $ ETag.formatTagsFile tags')+                                  generated+                    void $ timedNF timings GenerateTags (pure generated)+                    void $ timedNF timings MergeTags (pure tags')+                    withAtomicFile tagsFile $ \h -> timed timings WriteTags $ do+                      BSL.hPut h (BB.toLazyByteString $ ETag.formatETagsFile tags')+                      when (debug opts) (hFlush h)+                      pure True               else do-                pr <- fmap partitionEithers <$> CTag.parseTagsFile tagsContent+                pr <- timedNF timings ParseTags $ CTag.parseTagsFileRecovering tagsContent                 case pr of                   Left err ->                     printMessageDoc dynFlags ParserException Nothing err -                  Right (headers, tags) -> do-                    let tags' :: [Either CTag.Header CTag]-                        tags' = Left `map` headers+                  Right (warnings, parsed) -> do+                    mapM_ (printMessageDoc dynFlags ParseWarning Nothing) warnings+                    let tags = rights parsed+                        generated = prepareTagsWithOptions opts CTag.compareTags $ newTags SingCTag+                        tags' :: [Either CTag.Header CTag]+                        tags' = Left `map` CTag.headers opts                              ++ Right `map`-                                sortBy CTag.compareTags-                                ( tags-                                  ++-                                  (fmap (fixTagFilePath cwd tagsDir)-                                    . ghcTagToTag SingCTag dynFlags)-                                    `mapMaybe`-                                    hsDeclsToGhcTags Nothing decls-                                )-                    BSL.writeFile tagsFile (BB.toLazyByteString $ CTag.formatTagsFile tags')+                                (sortBy CTag.compareTags+                                  ( tags+                                    +++                                    generated+                                  ))+                    void $ timedNF timings GenerateTags (pure generated)+                    void $ timedNF timings MergeTags (pure tags')+                    withAtomicFile tagsFile $ \h -> timed timings WriteTags $ do+                      BSL.hPut h (BB.toLazyByteString $ CTag.formatTagsFile tags')+                      when (debug opts) (hFlush h)+                      pure True        Failure (ParserFailure f)  ->         withMetaD defaultRunMeta request expr $ \_ ->@@ -761,10 +740,72 @@       MetaAW k -> k <$> metaRequestAW h e  +-- Report after releasing the lock, including atomic replacement in total time.+withUpdateTimings :: Options Identity -> DynFlags -> Module -> String -> (Timings -> IO a) -> IO a+withUpdateTimings Options {debug, etags, stream, filePath = Identity tagsFile} dynFlags mod_ hook action =+    withTimings debug+      (\report -> printMessageDoc dynFlags DebugMessage (Just mod_)+        (formatTimings report ++ " hook=" ++ hook ++ " mode=" ++ mode ++ " tags=" ++ show tagsFile)) $+      \timings -> timedLock timings (withFileLock_ (lockFilePath tagsFile) ExclusiveLock) (action timings)+  where+    mode | etags = "etags"+         | stream && hook == "parsed" = "stream"+         | otherwise = "ctags"+++-- Sorting is required for merging even when filtering is disabled.+prepareTagsWithOptions :: Options Identity -> (Tag tk -> Tag tk -> Ordering) -> [Tag tk] -> [Tag tk]+prepareTagsWithOptions Options {filterOption} compareFn =+  case filterOption of+    RetainAll   -> sortBy compareFn+    RemoveTerms -> prepareTags compareFn+++-- | Add aliases only to newly extracted declarations, before sorting and+-- merging them with tags from other modules.+qualifyTags :: Options Identity -> Module -> [Tag tk] -> [Tag tk]+qualifyTags Options {qualified} mod_+  | qualified = addQualifiedTags (Text.pack $ moduleNameString $ moduleName mod_)+  | otherwise = id++ -- -- File path utils -- +-- GHC source filenames already include the component's -working-dir prefix.+-- Use this directory for project discovery, while resolving source filenames+-- against the process working directory as before.+compilerWorkingDirectory :: DynFlags -> IO FilePath+compilerWorkingDirectory dynFlags =+    Directory.makeAbsolute (fromMaybe "." (GHC.workingDirectory dynFlags))+++-- | Resolve tags file path relative to `cabal.project`, `cabal.project.local`,+-- `stack.yaml` or if none of them is found relative to last found `*.cabal`+-- file.+withResolvedOptions :: DynFlags+                    -- ^ GHC configuration+                    -> Maybe Module+                    -- ^ module being inspected+                    -> Options Identity+                    -- ^ user options+                    -> (Options Identity -> IO a)+                    -- ^ continuation+                    -> IO a+withResolvedOptions dynFlags mod_ opts@Options {filePath = Identity path} action = do+    cwd <- compilerWorkingDirectory dynFlags+    stackYaml <- traverse Directory.makeAbsolute =<< lookupEnv "STACK_YAML"+    resolveTagsFile cwd stackYaml path >>= \case+      TagsAtProjectRoot tagsFile ->+        action opts { filePath = Identity tagsFile }++      TagsAtPackageRoot tagsFile -> do+        printMessageDoc dynFlags ProjectRootWarning mod_ $+          ("No cabal.project or stack.yaml found; using " ++ tagsFile)+        action opts { filePath = Identity tagsFile }++ fixFilePath :: RawFilePath             -- ^ current directory             -> RawFilePath@@ -795,6 +836,8 @@ lockFilePath :: FilePath -> FilePath lockFilePath tagsFile =     case FilePath.splitFileName tagsFile of+      (dir, name@('.':_))+                  -> dir FilePath.</>        name ++ ".lock"       (dir, name) -> dir FilePath.</> "." ++ name ++ ".lock"  --@@ -808,35 +851,31 @@  messageDoc :: MessageType -> Maybe Module -> String -> Out.SDoc messageDoc errorType mb_mod errorMessage =-    Out.blankLine-      $+$         Out.coloured PprColour.colBold           (Out.text "GhcTagsPlugin: "             Out.<> Out.coloured messageColour (Out.text $ show errorType))-      $$-        case mb_mod of-          Just mod_ ->-            Out.coloured PprColour.colBold (Out.nest 4 $ Out.ppr mod_)-          Nothing -> Out.empty-      $$-        (Out.nest 8 $ Out.coloured messageColour (Out.text errorMessage))-      $+$-        Out.blankLine+      $$ case mb_mod of+        Just mod_ ->+          Out.coloured PprColour.colBold (Out.nest 4 $ Out.ppr mod_)+        Nothing ->+          Out.empty+      $$ (Out.nest 8 $ Out.coloured messageColour (Out.text errorMessage))       $+$ case severity of         Error ->           Out.coloured PprColour.colBold (Out.text "Please report this bug to: ")             Out.<> Out.text "https://github.com/coot/ghc-tags-plugin/issues"-          $+$ Out.blankLine-        Warning -> Out.blankLine-        Debug -> Out.blankLine+        Warning -> Out.empty+        Debug   -> Out.empty   where     severity = case errorType of       ReadException       -> Error       ParserException     -> Error+      ParseWarning        -> Warning       WriteException      -> Error       UnhandledException  -> Error       OptionParserFailure -> Warning       SizeWarning         -> Warning+      ProjectRootWarning  -> Warning       DebugMessage        -> Debug      messageColour = case severity of
+ lib/Plugin/GhcTags/AtomicFile.hs view
@@ -0,0 +1,41 @@+module Plugin.GhcTags.AtomicFile (withAtomicFile) where++import Control.Exception (bracket, finally)+import Control.Monad (when)+import System.Directory (canonicalizePath, copyPermissions, removeFile, renameFile)+import System.FilePath (takeDirectory, takeFileName)+import System.IO (Handle, hClose, openBinaryTempFile)+import System.IO.Error (catchIOError, isDoesNotExistError)++-- | Build a replacement beside the destination, then publish it with a rename.+-- The caller must hold the destination's lock for the whole read/modify/write.+-- Returning False leaves the original untouched (e.g. after a parse error).+--+-- If @path@ is a symlink the rename updates the symlink's target, not the+-- symlink itself, so a shared tags file stays in sync with the configured+-- path.+withAtomicFile :: FilePath -> (Handle -> IO Bool) -> IO ()+withAtomicFile path write = do+    -- Resolve symlinks so the temp file lands beside the real destination and+    -- the final rename updates the target rather than replacing the symlink.+    -- Falls back to the original path on the first write (file doesn't exist).+    dest <- canonicalizePath path `catchIOError` \_ -> pure path+    bracket+      (openBinaryTempFile (takeDirectory dest) ("." ++ takeFileName dest ++ ".tmp"))+      (\(tmp, h) -> hClose h `finally` removeTemp tmp)+      (\(tmp, h) -> do+          commit <- write h+          -- Close before renaming; this is subtle.  If `hClose` throws, we+          -- won't overwrite the `dest`, if we'd just use `hFlush` instead, it+          -- could succeed, then we'd overwrite the `dest` and `hClose` and+          -- `hClose` fail afterwards.+          hClose h+          when commit $ do+            -- Preserve the destination's permissions on the replacement.+            -- Silently skip when the destination doesn't exist yet (first write).+            copyPermissions dest tmp `catchIOError` \err ->+              if isDoesNotExistError err then pure () else ioError err+            renameFile tmp dest)+  where+    removeTemp tmp = removeFile tmp `catchIOError` \err ->+      if isDoesNotExistError err then pure () else ioError err
lib/Plugin/GhcTags/CTag.hs view
@@ -1,22 +1,26 @@+{-# LANGUAGE NamedFieldPuns    #-} {-# LANGUAGE OverloadedStrings #-}  module Plugin.GhcTags.CTag where +import           Data.Functor.Identity+import           Data.Text (Text) import qualified Data.Text as Text-import Data.Version+import           Data.Version -import Paths_ghc_tags_plugin+import           Paths_ghc_tags_plugin +import           Plugin.GhcTags.Options (Options (..), Filter (..)) import           GhcTags.CTag.Header  -headers :: [Header]-headers =+headers :: Options Identity -> [Header]+headers options =     [ Header FileFormat     Nothing 2 ""     , Header FileSorted     Nothing 1 ""     , Header FileEncoding   Nothing "utf-8" ""     , Header ProgramAuthor  Nothing "Marcin Szamotulski" ""-    , Header ProgramName    Nothing "ghc-tags-plugin" ""+    , Header ProgramName    Nothing "ghc-tags-plugin" (displayOptions options)     , Header ProgramUrl     Nothing "https://hackage.haskell.org/package/ghc-tags-plugin" ""     , Header ProgramVersion Nothing (Text.pack $ showVersion version) "" @@ -34,18 +38,40 @@     , Header KindDescription haskellLang "g" "gadt constructor"     , Header KindDescription haskellLang "r" "record field"     , Header KindDescription haskellLang "≡" "type synonym"-    , Header KindDescription haskellLang "~" "type signature"+    , Header KindDescription haskellLang "⊢" "type signature"     , Header KindDescription haskellLang "p" "pattern synonym"     , Header KindDescription haskellLang "C" "type class"     , Header KindDescription haskellLang "m" "type class member"     , Header KindDescription haskellLang "i" "type class instance"     , Header KindDescription haskellLang "x" "type class instance member"-    , Header KindDescription haskellLang "F" "type family"-    , Header KindDescription haskellLang "f" "type family instance"-    , Header KindDescription haskellLang "D" "data type family"-    , Header KindDescription haskellLang "d" "data type family instance"+    , Header KindDescription haskellLang "f" "type family"+    , Header KindDescription haskellLang "F" "type family instance"+    , Header KindDescription haskellLang "d" "data type family"+    , Header KindDescription haskellLang "D" "data type family instance"     , Header KindDescription haskellLang "I" "foreign import"     , Header KindDescription haskellLang "E" "foreign export"     ]   where     haskellLang = Just "Haskell"+++displayOptions :: Options Identity -> Text+displayOptions Options { etags,+                         stream,+                         filePath = Identity filePath,+                         debug,+                         qualified,+                         filterOption } =+    Text.intercalate " " $+      [ "--etags"  | etags ]+      +++      [ "--stream" | stream ]+      +++      [ "--debug"  | debug ]+      +++      [ "--qualified" | qualified ]+      +++      [ "--no-filter" | RetainAll <- pure filterOption ]+      +++      [ Text.pack filePath ]+
lib/Plugin/GhcTags/FileLock.hs view
@@ -1,26 +1,25 @@ module Plugin.GhcTags.FileLock-  ( withFileLock+  ( withFileLock_   , LockMode (..)   ) where  import           Control.Exception-import           Control.Monad (when)- import           Lukko  -- | 'flock' base lock (on posix) or `LockFileEx` on Windows. ---withFileLock :: Bool -- ^ debug option-             -> FilePath -> LockMode -> (FD -> IO x) -> IO x-withFileLock debug path mode k =-    bracket-      (fdOpen path)-      (\h -> fdClose h)-      $ \h ->-        bracket-          (do fdLock h mode-              when debug (putStrLn "lock: taken"))-          (\_ ->-           do when debug (putStrLn "lock: releasing")-              fdUnlock h)-          (\_ -> k h)+withFileLock_ :: FilePath+              -> LockMode+              -> IO a+              -> IO a+withFileLock_ path mode io =+  bracket+    (fdOpen path)+    fdClose+    $ \h ->+      -- lock the file using internal bracket, so if `fdLock` throws, we will+      -- still close the file handle.+      bracket_+        (fdLock h mode)+        (fdUnlock h)+        io
lib/Plugin/GhcTags/Options.hs view
@@ -5,6 +5,7 @@ module Plugin.GhcTags.Options   ( Options (..)   , ParserResult (..)+  , Filter (..)   , runOptionParser   ) where @@ -13,7 +14,9 @@ import           Data.Functor.Identity (Identity (..)) import           Options.Applicative +import           GhcTags.Ghc (Filter (..)) + etagsParser :: Parser Bool etagsParser = switch $        short 'e'@@ -32,15 +35,30 @@ filePathParser :: Parser FilePath filePathParser =     strArgument $-         help "tags file: default tags or TAGS (when --etags is specified)"+         help "tags file relative to cabal.project or stack.yaml: default tags or TAGS (with --etags)"       <> metavar "file_path"  debugParser :: Parser Bool debugParser = switch $        long "debug"     <> showDefault-    <> help "debug"+    <> help "report tag counts, file sizes, and update timings" +qualifiedParser :: Parser Bool+qualifiedParser = switch $+       short 'q'+    <> long "qualified"+    <> help "also generate names qualified by the defining module"++filterParser :: Parser Filter+filterParser =+        bool RemoveTerms RetainAll+    <$> switch (+             short 'F'+          <> long "no-filter"+          <> help "retain both signatures and definitions instead of filtering redundant tags"+        )+ -- | /ghc-tags-plugin/ options -- data Options f = Options@@ -54,10 +72,17 @@     -- file destination.    , filePath :: f FilePath-    -- ^ file path to the tags file (relative to the @*.cabal@ file).  The+    -- ^ file path to the tags file, relative to @cabal.project@ or @stack.yaml@.+    -- If neither is found, use the @*.cabal@ package directory and warn. The     -- default is either 'tags' (if 'etags' if 'False') or 'TAGS' otherwise.    , debug :: Bool++  , qualified :: Bool+    -- ^ Also generate module-qualified aliases alongside ordinary names.++  , filterOption :: Filter+    -- ^ Retain signatures and definitions; filtering is enabled by default.   }  deriving instance Show (Options Identity)@@ -70,6 +95,8 @@          <*> streamParser          <*> (foldMap (Last . Just) <$> many filePathParser)          <*> debugParser+         <*> qualifiedParser+         <*> filterParser   parserInfo :: ParserInfo (Options Last)@@ -83,12 +110,14 @@ runOptionParser = fmap defaultOptions . execParserPure defaultPrefs parserInfo   where     defaultOptions :: Options Last -> Options Identity-    defaultOptions Options { etags, stream, filePath, debug } =+    defaultOptions Options { etags, stream, filePath, debug, qualified, filterOption } =         Options {             etags,             stream,             filePath = Identity filePath',-            debug+            debug,+            qualified,+            filterOption           }       where         filePath' =
+ lib/Plugin/GhcTags/Project.hs view
@@ -0,0 +1,71 @@+module Plugin.GhcTags.Project+  ( TagsAtRoot (..)+  , resolveTagsFile+  ) where++import Data.Maybe (fromMaybe)+import System.Directory (canonicalizePath, doesFileExist, listDirectory)+import System.FilePath (isAbsolute, normalise, takeDirectory, takeExtension, (</>))+++-- Resolved tag file path+data TagsAtRoot = TagsAtProjectRoot FilePath+                | TagsAtPackageRoot FilePath+  deriving (Show, Eq)+++-- | Resolve an output path from the compiler's working directory and optional+-- STACK_YAML setting. A fallback directory is returned when no project+-- configuration was found, so the caller can report a warning.+resolveTagsFile :: FilePath+                -- ^ current working directory+                -> Maybe FilePath+                -- ^ `stack.yaml` absolute path (read from `STACK_YAML`+                -- variable)+                -> FilePath+                -- ^ user supplied tags file path+                -> IO TagsAtRoot+                -- ^ tuple of resolved patch and a fallback +resolveTagsFile _ _ path | isAbsolute path = pure (TagsAtProjectRoot path)+resolveTagsFile cwd stackYaml path = do+    start <- canonicalizePath cwd+    stackRoot <- case stackYaml of+      Just config | not (null config) -> do+        let configPath = cwd </> config+        exists <- doesFileExist configPath+        if exists+          then Just . takeDirectory <$> canonicalizePath configPath+          else pure Nothing+      _ -> pure Nothing+    case stackRoot of+      Just root -> pure (TagsAtProjectRoot $ normalise (root </> path))+      Nothing -> search start Nothing start+  where+    search start packageRoot dir = do+      configs <- traverse (doesFileExist . (dir </>))+                   [ "cabal.project"+                   , "cabal.project.local"+                   , "stack.yaml"+                   ]+      if or configs+        -- found project config+        then pure (TagsAtProjectRoot $ normalise (dir </> path))+        else do+          -- look for `*.cabal` files+          packageRoot' <- case packageRoot of+            Just _ -> pure packageRoot+            Nothing -> do+              entries <- listDirectory dir+              packages <- traverse (doesFileExist . (dir </>))+                (filter ((== ".cabal") . takeExtension) entries)+              pure $ if or packages then Just dir else Nothing+          let parent = takeDirectory dir+          if parent == dir+            then do+              -- We reached file system root directory, resolve tags file+              -- relative to last found package root.+              let root = fromMaybe start packageRoot'+              pure (TagsAtPackageRoot $ normalise (root </> path))+            else+              -- continue at the parent directory+              search start packageRoot' parent
+ lib/Plugin/GhcTags/Timing.hs view
@@ -0,0 +1,125 @@+{-# LANGUAGE NamedFieldPuns #-}++-- | Debug-only wall-clock measurements. Nested phases are charged separately.+module Plugin.GhcTags.Timing+  ( Timings+  , Phase (..)+  , TimingReport (..)+  , withTimings+  , withTimingsUsing+  , formatTimings+  , timed+  , timedNF+  , timedProducer+  , timedLock+  ) where++import Control.DeepSeq (NFData, force, rnf)+import Control.Exception (evaluate)+import Control.Monad.IO.Class (MonadIO, liftIO)+import Data.IORef+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Word (Word64)+import GHC.Clock (getMonotonicTimeNSec)+import qualified Pipes+import Text.Printf (printf)+++data Phase = LockWait | ReadTags | ParseTags | GenerateTags | MergeTags | WriteTags+  deriving (Eq, Ord, Show, Enum, Bounded)++data Timings = Disabled | Enabled (IO Word64) (IORef (Map Phase Word64))++data TimingReport = TimingReport+  { totalNanos :: Word64+  , phaseNanos :: Map Phase Word64+  } deriving (Eq, Show)+++withTimings :: Bool -> (TimingReport -> IO ()) -> (Timings -> IO a) -> IO a+withTimings = withTimingsUsing getMonotonicTimeNSec+++-- | Injectable clock for deterministic tests. Reports completed updates only.+withTimingsUsing :: IO Word64 -> Bool -> (TimingReport -> IO ()) -> (Timings -> IO a) -> IO a+withTimingsUsing _ False _ action = action Disabled+withTimingsUsing clock True report action = do+    phases <- newIORef $ Map.fromList [(phase, 0) | phase <- [minBound .. maxBound]]+    start <- clock+    result <- action (Enabled clock phases)+    end <- clock+    elapsed <- readIORef phases+    report (TimingReport (end - start) elapsed)+    pure result+++-- | Measure an action, subtracting time already charged to nested phases.+-- The action's result must be forced by the caller if evaluating it is part+-- of the measured work. No clocks or counters are used when disabled.+timed :: MonadIO m => Timings -> Phase -> m a -> m a+timed Disabled _ action = action+timed (Enabled clock phases) phase action = do+    before <- liftIO $ Map.foldl' (+) 0 <$> readIORef phases+    start <- liftIO clock+    result <- action+    end <- liftIO clock+    after <- liftIO $ Map.foldl' (+) 0 <$> readIORef phases+    liftIO $ addTime phases phase (end - start - (after - before))+    pure result+++timedNF :: NFData a => Timings -> Phase -> IO a -> IO a+timedNF Disabled _ action = action+timedNF timings phase action = timed timings phase (action >>= evaluate . force)+++-- | Time each pull, excluding downstream consumers and charging upstream+-- timed producers to their own phases. Retains streaming space usage.+timedProducer :: (MonadIO m, NFData a)+              => Timings -> Phase -> Pipes.Producer a m r -> Pipes.Producer a m r+timedProducer Disabled _ producer = producer+timedProducer timings phase producer = do+    next <- Pipes.lift $ timed timings phase $ do+      result <- Pipes.next producer+      case result of+        Left _ -> pure ()+        Right (value, _) -> liftIO (evaluate $ rnf value)+      pure result+    case next of+      Left result -> pure result+      Right (value, rest) -> Pipes.yield value >> timedProducer timings phase rest+++-- | Measure only acquisition, not the protected action or lock release.+timedLock :: Timings+          -> (IO a -> IO a)+          -- ^ with lock combinator+          -> IO a+          -> IO a+timedLock Disabled withLock action = withLock action+timedLock (Enabled clock phases) withLock action = do+    start <- clock+    withLock $ do+      acquired <- clock+      addTime phases LockWait (acquired - start)+      action+++addTime :: IORef (Map Phase Word64) -> Phase -> Word64 -> IO ()+addTime phases phase elapsed =+    modifyIORef' phases (Map.insertWith (+) phase elapsed)+++formatTimings :: TimingReport -> String+formatTimings TimingReport {totalNanos, phaseNanos} =+    unwords ("timing:" : milliseconds "total" totalNanos+                      : [milliseconds (name phase) elapsed | (phase, elapsed) <- Map.toAscList phaseNanos])+  where+    milliseconds label nanos = printf "%s=%.3fms" label (fromIntegral nanos / 1000000 :: Double)+    name LockWait = "lock-wait"+    name ReadTags = "read"+    name ParseTags = "parse"+    name GenerateTags = "generate"+    name MergeTags = "merge"+    name WriteTags = "write"
+ test/Test/AtomicFile.hs view
@@ -0,0 +1,111 @@+module Test.AtomicFile (tests) where++import Control.Concurrent+import Control.Exception+import System.Directory+import System.FilePath ((</>))+import System.IO++import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit++import Plugin.GhcTags.AtomicFile++tests :: TestTree+tests = testGroup "Atomic file updates"+    [ testCase "aborted initial update leaves no files" testAbortedInitialUpdate+    , testCase "successful update publishes replacement" testSuccessfulUpdate+    , testCase "aborted update preserves existing tags" testAbortedUpdate+    , testCase "write exception preserves existing tags" testWriteException+    , testCase "cancellation preserves tags and permits the next update" testCancellation+    ]+++testAbortedInitialUpdate :: Assertion+testAbortedInitialUpdate = withTestDirectory $ \dir -> do+    withAtomicFile (dir </> "tags") $ \h ->+      hPutStr h "discarded\n" >> pure False+    listDirectory dir >>= (@?= [])+++testSuccessfulUpdate :: Assertion+testSuccessfulUpdate = withTagsFile $ \dir path -> do+    withAtomicFile path $ \h -> hPutStr h "replacement\n" >> pure True+    checkTagsFile dir path "replacement\n"+++testAbortedUpdate :: Assertion+testAbortedUpdate = withTagsFile $ \dir path -> do+    withAtomicFile path $ \h -> hPutStr h "partial\n" >> pure False+    checkTagsFile dir path original+++testWriteException :: Assertion+testWriteException = withTagsFile $ \dir path -> do+    result <- try $ withAtomicFile path $ \h -> do+      hPutStr h "partial\n"+      hFlush h+      throwIO (userError "write failed")+    case result :: Either IOException () of+      Left _ -> pure ()+      Right _ -> assertFailure "write exception swallowed"+    checkTagsFile dir path original+++testCancellation :: Assertion+testCancellation = withTagsFile $ \dir path -> do+    -- Cancel after partial output has reached the temporary file.+    started <- newEmptyMVar+    blocker <- newEmptyMVar :: IO (MVar ())+    finished <- newEmptyMVar+    let writer = do+          outcome <- try $ withAtomicFile path $ \h -> do+            hPutStr h "partial\n"+            hFlush h+            putMVar started ()+            takeMVar blocker+            pure True+          putMVar finished (outcome :: Either SomeException ())+    bracket (forkIO writer) killThread $ \tid -> do+      takeMVar started+      -- Readers see complete old contents while the update is in progress.+      readFile path >>= (@?= original)+      throwTo tid ThreadKilled+      outcome <- takeMVar finished+      case outcome of+        Left err -> fromException err @?= Just ThreadKilled+        Right _ -> assertFailure "cancellation swallowed"+      checkTagsFile dir path original+      withAtomicFile path $ \h -> hPutStr h "recovered\n" >> pure True+      checkTagsFile dir path "recovered\n"+++--+-- Utils+--++original :: String+original = "existing tags\n"+++withTestDirectory :: (FilePath -> IO a) -> IO a+withTestDirectory action = do+    tmp <- getTemporaryDirectory+    bracket (do (path, h) <- openTempFile tmp "ghc-tags-atomic-test"+                hClose h+                removeFile path+                createDirectory path+                pure path)+            removeDirectoryRecursive+            action++withTagsFile :: (FilePath -> FilePath -> IO a) -> IO a+withTagsFile action = withTestDirectory $ \dir -> do+    let path = dir </> "tags"+    writeFile path original+    action dir path++checkTagsFile :: FilePath -> FilePath -> String -> Assertion+checkTagsFile dir path expected = do+    readFile path >>= (@?= expected)+    listDirectory dir >>= (@?= ["tags"])
+ test/Test/Check.hs view
@@ -0,0 +1,199 @@+{-# LANGUAGE NamedFieldPuns    #-}+{-# LANGUAGE OverloadedStrings #-}++module Test.Check (tests) where++import Control.Monad (forM_)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BSC+import Data.Char (isControl)+import Data.List ((\\), group, nub, sort)+import qualified Data.Map.Strict as Map+import Data.Maybe (mapMaybe)+import qualified Data.Text as Text+import System.Directory (canonicalizePath, createDirectory, createDirectoryIfMissing)+import System.FilePath (joinPath, splitDirectories, takeDirectory, (</>))+import System.IO.Temp (withSystemTempDirectory)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.QuickCheck+import Test.QuickCheck.Instances.ByteString ()+import Test.QuickCheck.Instances.Text ()++import GhcTags.Check+++tests :: TestTree+tests = testGroup "Check"+  [ testProperty "duplicate declarations match grouped locations" duplicatesProp+  , testProperty "comparison matches list multiset difference" comparisonProp+  , testProperty "source line count matches splitting into lines" sourceLinesProp+  , testProperty "source diagnostics match the generated files" sourceChecksProp+  , testProperty "source paths are relative to each tags file" relativePathsProp+  ]+++newtype ArbLocation = ArbLocation { getTagLocation :: TagLocation }+  deriving Show++instance Arbitrary ArbLocation where+    arbitrary = fmap ArbLocation $ TagLocation+      <$> elements sourcePaths+      <*> frequency [ (5, Just <$> choose (-2, 8))+                    , (1, pure Nothing)+                    ]+      <*> frequency [ (4, elements ["foo", "bar", "baz"])+                    , (1, fixName <$> arbitrary)+                    ]+    shrink (ArbLocation tag@TagLocation {sourceLine, sourceName}) =+         [ArbLocation tag {sourceLine = line} | line <- shrink sourceLine]+      ++ [ ArbLocation tag {sourceName = name}+         | name <- map fixName $ shrink sourceName+         , name /= sourceName+         ]+++fixName :: Text.Text -> Text.Text+fixName name = case Text.filter (not . isControl) name of+    "" -> "a"+    valid -> valid+++sourcePaths :: [FilePath]+sourcePaths = ["A.hs", "source with spaces/B.hs", "sub/C.hs"]+++newtype Locations = Locations [TagLocation]+  deriving Show++instance Arbitrary Locations where+    arbitrary = Locations . map getTagLocation <$> resize 30 arbitrary+    shrink (Locations tags) = map Locations $ shrinkList+      (\tag -> [smaller | ArbLocation smaller <- shrink (ArbLocation tag)]) tags+++modelDeclarations :: [TagLocation] -> [Declaration]+modelDeclarations =+    mapMaybe $+      \TagLocation {sourcePath, sourceLine, sourceName} ->+      (\line -> Declaration sourcePath line sourceName) <$> sourceLine+++duplicatesProp :: Locations -> Property+duplicatesProp (Locations tags) =+    let groups = group $ sort $ modelDeclarations tags+        expected = [(location, length locations) | locations@(location : _ : _) <- groups]+    in Map.toAscList (duplicateDeclarations tags) === expected+++comparisonProp :: Locations -> Locations -> Property+comparisonProp (Locations left) (Locations right) =+    let a = modelDeclarations left+        b = modelDeclarations right+        expected = (sort $ a \\ b, sort $ b \\ a)+        (onlyLeft, onlyRight) = compareDeclarations left right+        expand counts = concat [replicate count location | (location, count) <- Map.toAscList counts]+    in (expand onlyLeft, expand onlyRight) === expected+++newtype Contents = Contents BS.ByteString+  deriving Show++instance Arbitrary Contents where+    arbitrary = Contents <$> frequency+      [ (2, arbitrary)+      , (1, pure BS.empty)+      , (3, BS.intercalate "\n" <$> resize 6 arbitrary)+      , (1, (<> "\n") <$> arbitrary)+      ]+    shrink (Contents bytes) = map Contents $ shrink bytes+++sourceLinesProp :: Contents -> Property+sourceLinesProp (Contents bytes) = countSourceLines bytes === length (BSC.lines bytes)+++data SourceFile = MissingFile | ReadableFile BS.ByteString | SourceDirectory+  deriving (Eq, Show)++instance Arbitrary SourceFile where+    arbitrary = frequency+      [ (2, pure MissingFile)+      , (5, (\(Contents bytes) -> ReadableFile bytes) <$> arbitrary)+      , (1, pure SourceDirectory)+      ]+++data SourceExample = SourceExample+  { sourceFiles :: Map.Map FilePath SourceFile+  , sourceTags :: Locations+  }+  deriving Show++instance Arbitrary SourceExample where+    arbitrary = SourceExample+      <$> (Map.fromList <$> resize 6 (listOf $ (,)+            <$> elements sourcePaths+            <*> arbitrary))+      <*> arbitrary+    shrink SourceExample {sourceFiles, sourceTags} =+         [ SourceExample (Map.fromList smaller) sourceTags+         | smaller <- shrinkList (const []) (Map.toList sourceFiles)+         ]+      ++ [SourceExample sourceFiles smaller | smaller <- shrink sourceTags]+++sourceChecksProp :: SourceExample -> Property+sourceChecksProp SourceExample {sourceFiles, sourceTags = Locations tags} = ioProperty $+    withSystemTempDirectory "gtp-check-sources" $ \root -> do+      forM_ (Map.toList sourceFiles) $ \(path, contents) ->+        case contents of+          MissingFile -> pure ()+          ReadableFile bytes -> do+            createDirectoryIfMissing True (takeDirectory $ root </> path)+            BS.writeFile (root </> path) bytes+          SourceDirectory -> do+            createDirectoryIfMissing True (takeDirectory $ root </> path)+            createDirectory (root </> path)+      let absoluteTags = [tag {sourcePath = root </> sourcePath tag} | tag <- tags]+          paths = sort $ nub $ map sourcePath tags+          expectedMissing =+            [root </> path | path <- paths, Map.lookup path sourceFiles `elem` [Nothing, Just MissingFile]]+          expectedUnreadable =+            [root </> path | path <- paths, Map.lookup path sourceFiles == Just SourceDirectory]+          expectedInvalid =+            [ (Declaration (root </> path) line name, length $ BSC.lines bytes)+            | Declaration path line name <- sort $ nub $ modelDeclarations tags+            , Just (ReadableFile bytes) <- [Map.lookup path sourceFiles]+            , line < 1 || line > length (BSC.lines bytes)+            ]+          expected = (expectedMissing, expectedUnreadable, expectedInvalid)+      actual <- checkSources absoluteTags+      pure $ (missingSources actual, map fst $ unreadableSources actual, invalidLines actual) === expected+++data PathExample = PathExample FilePath FilePath+  deriving Show++instance Arbitrary PathExample where+    arbitrary = PathExample+      <$> (joinPath . ("sources" :) <$> resize 4 (listOf segment))+      <*> (joinPath . ("output" :) <$> resize 4 (listOf segment))+      where+        segment = elements ["a", "b", "directory with spaces"]+++relativePathsProp :: PathExample -> Property+relativePathsProp (PathExample sourceDir tagsDir) = ioProperty $+    withSystemTempDirectory "gtp-check-paths" $ \root -> do+      let source = root </> sourceDir </> "A.hs"+          output = root </> tagsDir+          relative = joinPath (replicate (length $ splitDirectories tagsDir) "..")+                   </> sourceDir </> "A.hs"+          tag = TagLocation source (Just 1) "foo"+      createDirectoryIfMissing True (takeDirectory source)+      createDirectoryIfMissing True output+      BS.writeFile source "foo = ()\n"+      expected <- canonicalizePath source+      fromRelative <- resolveTagPaths output [tag {sourcePath = relative}]+      fromAbsolute <- resolveTagPaths root [tag]+      pure $ (fromRelative, fromAbsolute) === ([tag {sourcePath = expected}], [tag {sourcePath = expected}])
+ test/Test/Ghc.hs view
@@ -0,0 +1,206 @@+{-# LANGUAGE CPP            #-}+{-# LANGUAGE NamedFieldPuns  #-}++{-# OPTIONS_GHC -Wno-orphans #-}++module Test.Ghc (tests) where++import qualified Data.ByteString.Char8 as BSC+import Data.Foldable (toList)+import Data.List (sort)+import GHC.Hs+import GHC.Types.Name.Occurrence (occNameString)+import GHC.Types.Name.Reader (rdrNameOcc)+import GHC.Types.SrcLoc (GenLocated (..), unLoc)+import qualified GHC.Utils.Outputable as Out+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.QuickCheck++import GhcTags.Ghc+import Test.Ghc.Generators+++tests :: TestTree+tests = testGroup "Ghc"+    [ testProperty "pattern signature filtering follows the option within each binding group" patternSignatureProp+    , testProperty "method visibility follows class exports and lexical scope" methodExportsProp+    , testProperty "family tags follow declarations and exports" familyExportsProp+    ]++instance Arbitrary Filter where+  arbitrary = elements [RetainAll, RemoveTerms]+  shrink RetainAll   = [RemoveTerms]+  shrink RemoveTerms = []+  ++patternSignatureProp :: Filter -> [ArbHsDecl] -> Property+patternSignatureProp filterBindings input =+    let declarations = map getArbHsDecl input+        tags = hsDeclsToGhcTags filterBindings Nothing declarations+        actual =+          [ (BSC.unpack gtTag, kind, signatureType)+          | GhcTag {gtTag, gtKind} <- tags+          , let (kind, signatureType) = case gtKind of+                  GtkTypeSignature ty ->+                    ("signature", Out.renderWithContext Out.defaultSDocContext $ Out.ppr ty)+                  GtkPatternSynonym -> ("pattern", "")+                  _ -> ("unexpected", "")+          ]+        declarationsModel = concatMap (\decl -> case unLoc decl of+          SigD _ (PatSynSig _ names signature) ->+            [ (occNameString $ rdrNameOcc $ unLoc name, "signature",+               Out.renderWithContext Out.defaultSDocContext $ Out.ppr signature)+            | name <- names+            ]+          ValD _ (PatSynBind _ PSB {psb_id}) ->+            [(occNameString $ rdrNameOcc $ unLoc psb_id, "pattern", "")]+          _ -> []) declarations+        signatures = [name | (name, "signature", _) <- declarationsModel]+        definitions = [name | (name, "pattern", _) <- declarationsModel]+        expected =+          filter+            (\(name, kind, _) -> filterBindings == RetainAll+                              || kind /= "pattern"+                              || name `notElem` signatures)+          declarationsModel+    in checkCoverage+       $ cover 25 (filterBindings == RemoveTerms) "filtering enabled"+       $ cover 25 (filterBindings == RetainAll) "filtering disabled"+       $ cover 30 (not (null signatures) && not (null definitions)) "signatures and definitions"+       $ cover 20 (any (`elem` definitions) signatures) "matching signature and definition names"+       $ cover 20 (any (`notElem` signatures) definitions) "unsigned pattern definitions"+       $ actual === expected+++methodExportsProp :: ArbClassModule -> Property+methodExportsProp (ArbClassModule ast@(L _ HsModule {hsmodDecls, hsmodExports})) =+    let nameOf = occNameString . rdrNameOcc . unLoc+        wrappedName = occNameString . rdrNameOcc . ieWrappedName . unLoc+        classes =+          [ (nameOf tcdLName, map nameOf $ concatMap (signatureNames . unLoc) tcdSigs)+          | L _ (TyClD _ ClassDecl {tcdLName, tcdSigs}) <- hsmodDecls+          ]+        membersOf className = concat [names | (name, names) <- classes, name == className]+        exportedMembers :: IE GhcPs -> [String]+        exportedMembers ie = case ie of+#if MIN_VERSION_GLASGOW_HASKELL(9,10,0,0)+          IEVar _ name _ -> [wrappedName name]+          IEThingAll _ name _ -> membersOf (wrappedName name)+          IEThingWith _ name wildcard children _ ->+#else+          IEVar _ name -> [wrappedName name]+          IEThingAll _ name -> membersOf (wrappedName name)+          IEThingWith _ name wildcard children ->+#endif+            case wildcard of+              IEWildcard {} -> membersOf (wrappedName name)+              NoIEWildcard -> filter (`elem` map wrappedName children) (membersOf $ wrappedName name)+          _ -> []+        publicNames = case hsmodExports of+          Nothing -> concatMap snd classes+          Just exports -> concatMap (exportedMembers . unLoc) (unLoc exports)+        directNames = concatMap (\decl -> case unLoc decl of+          TyClD _ ClassDecl {tcdSigs, tcdMeths} ->+               concatMap (signatureNames . unLoc) tcdSigs+            ++ [fun_id | L _ FunBind {fun_id} <- toList tcdMeths]+          InstD _ (ClsInstD _ ClsInstDecl {cid_sigs, cid_binds}) ->+               concatMap (signatureNames . unLoc) cid_sigs+            ++ [fun_id | L _ FunBind {fun_id} <- toList cid_binds]+          _ -> []) hsmodDecls+        publicLocations = [getLocA name | name <- directNames, nameOf name `elem` publicNames]+        directLocations = map getLocA directNames+        tags = filter (\tag -> case gtKind tag of+                         GtkModule -> False+                         GtkTypeClass -> False+                         GtkTypeClassInstance {} -> False+                         _ -> True) (getGhcTags RetainAll ast)+        expected = [gtSrcSpan tag `elem` publicLocations | tag <- tags]+        shadowsExport = any (\tag -> BSC.unpack (gtTag tag) `elem` publicNames+                                 && gtSrcSpan tag `notElem` directLocations) tags+    in checkCoverage+       $ cover 20 (not $ null publicLocations) "exported methods"+       $ cover 20 (any ((`notElem` publicNames) . nameOf) directNames) "hidden methods"+       $ cover 10 shadowsExport "local bindings shadow exported methods"+       $ counterexample (show [(BSC.unpack $ gtTag tag, gtSrcSpan tag) | tag <- tags])+       $ map gtIsExported tags === expected+  where+    signatureNames :: Sig GhcPs -> [LIdP GhcPs]+    signatureNames (ClassOpSig _ _ names _) = names+    signatureNames (TypeSig _ names _) = names+    signatureNames _ = []+++familyExportsProp :: ArbFamilyModule -> Property+familyExportsProp (ArbFamilyModule ast@(L _ HsModule {hsmodDecls, hsmodExports})) =+    let nameOf = occNameString . rdrNameOcc . unLoc+        wrappedName = occNameString . rdrNameOcc . ieWrappedName . unLoc+        typeInstanceName :: TyFamInstDecl GhcPs -> LIdP GhcPs+        typeInstanceName TyFamInstDecl {tfid_eqn = FamEqn {feqn_tycon}} = feqn_tycon+        dataInstanceNames :: DataFamInstDecl GhcPs -> ([LIdP GhcPs], [(String, [String])])+        dataInstanceNames DataFamInstDecl {dfid_eqn = FamEqn {feqn_tycon, feqn_rhs = HsDataDefn {dd_cons}}} =+          let children = concatMap (constructorNames . unLoc) dd_cons+          in (feqn_tycon : children, [(nameOf feqn_tycon, map nameOf children)])+        declarations = map (\decl -> case unLoc decl of+          TyClD _ (FamDecl _ FamilyDecl {fdLName}) -> ([fdLName], [])+          TyClD _ ClassDecl {tcdLName, tcdATs, tcdATDefs} ->+            let members = [fdLName | L _ FamilyDecl {fdLName} <- tcdATs]+            in (members ++ map (typeInstanceName . unLoc) tcdATDefs,+                [(nameOf tcdLName, map nameOf members)])+          InstD _ (TyFamInstD _ decl') -> ([typeInstanceName decl'], [])+          InstD _ (DataFamInstD _ decl') -> dataInstanceNames decl'+          InstD _ (ClsInstD _ ClsInstDecl {cid_tyfam_insts, cid_datafam_insts}) ->+            let dataInstances = map (dataInstanceNames . unLoc) cid_datafam_insts+            in (map (typeInstanceName . unLoc) cid_tyfam_insts ++ concatMap fst dataInstances,+                concatMap snd dataInstances)+          _ -> ([], [])) hsmodDecls+        names = concatMap fst declarations+        childrenOf parent = concat [children | (_, edges) <- declarations,+                                               (name, children) <- edges, name == parent]+        expandExport :: IE GhcPs -> [String]+        expandExport ie = case ie of+#if MIN_VERSION_GLASGOW_HASKELL(9,10,0,0)+          IEVar _ name _ -> [wrappedName name]+          IEThingAbs _ name _ -> [wrappedName name]+          IEThingAll _ name _ -> wrappedName name : childrenOf (wrappedName name)+          IEThingWith _ name wildcard children _ ->+#else+          IEVar _ name -> [wrappedName name]+          IEThingAbs _ name -> [wrappedName name]+          IEThingAll _ name -> wrappedName name : childrenOf (wrappedName name)+          IEThingWith _ name wildcard children ->+#endif+            wrappedName name : case wildcard of+              IEWildcard {} -> childrenOf (wrappedName name)+              NoIEWildcard -> map wrappedName children+          _ -> []+        publicNames = case hsmodExports of+          Nothing -> map nameOf names+          Just exports -> concatMap (expandExport . unLoc) (unLoc exports)+        expected = sort [(nameOf name, show $ getLocA name, nameOf name `elem` publicNames) | name <- names]+        tags = filter (\tag -> case gtKind tag of+                         GtkModule -> False+                         GtkTypeClass -> False+                         GtkTypeClassInstance {} -> False+                         _ -> True) (getGhcTags RetainAll ast)+        actual = sort [(BSC.unpack gtTag, show gtSrcSpan, gtIsExported) | GhcTag {gtTag, gtSrcSpan, gtIsExported} <- tags]+        hasDefault = or [not (null tcdATDefs) | L _ (TyClD _ ClassDecl {tcdATDefs}) <- hsmodDecls]+        hiddenChildren = or [parent `elem` publicNames && any (`notElem` publicNames) children+                            | (_, edges) <- declarations, (parent, children) <- edges]+    in checkCoverage+       $ cover 20 (any ((`elem` publicNames) . nameOf) names) "exported families or members"+       $ cover 10 (any ((`notElem` publicNames) . nameOf) names) "hidden families or members"+       $ cover 10 hasDefault "associated type defaults"+       $ cover 10 hiddenChildren "exported parents with hidden members"+       $ actual === expected+  where+    constructorNames :: ConDecl GhcPs -> [LIdP GhcPs]+    constructorNames ConDeclH98 {con_name, con_args} =+      con_name : case con_args of+        RecCon (L _ fields) ->+#if MIN_VERSION_GLASGOW_HASKELL(9,14,0,0)+          [foLabel field | L _ HsConDeclRecField {cdrf_names} <- fields, L _ field <- cdrf_names]+#else+          [foLabel field | L _ ConDeclField {cd_fld_names} <- fields, L _ field <- cd_fld_names]+#endif+        _ -> []+    constructorNames _ = []
+ test/Test/Ghc/Generators.hs view
@@ -0,0 +1,252 @@+{-# LANGUAGE CPP            #-}+{-# LANGUAGE NamedFieldPuns #-}++module Test.Ghc.Generators (ArbHsDecl (..), ArbClassModule (..), ArbFamilyModule (..)) where++import Data.Char (isUpper)+import Data.List (intercalate)+import qualified GHC.Data.EnumSet as EnumSet+import GHC.Data.FastString (mkFastString)+import GHC.Data.StringBuffer (stringToStringBuffer)+import GHC.Hs+import GHC.LanguageExtensions.Type (Extension (TypeFamilies, ExplicitNamespaces))+import qualified GHC.Parser as Parser+import GHC.Parser.Lexer (ParseResult (..), initParserState, mkParserOpts, unP)+import GHC.Types.Name.Occurrence (mkDataOcc, mkOccName, mkTcOcc, occNameSpace, occNameString)+import GHC.Types.Name.Reader (RdrName, mkRdrUnqual, rdrNameOcc)+import GHC.Types.SrcLoc (GenLocated (..), Located, mkRealSrcLoc, unLoc)+#if MIN_VERSION_GLASGOW_HASKELL(9,8,0,0)+import GHC.Utils.Error (emptyDiagOpts)+#else+import GHC.Utils.Error (DiagOpts (..))+#endif+import qualified GHC.Utils.Outputable as Out+import Test.Tasty.QuickCheck+++newtype ArbHsDecl = ArbHsDecl { getArbHsDecl :: LHsDecl GhcPs }++instance Show ArbHsDecl where+    show = Out.renderWithContext Out.defaultSDocContext . Out.ppr . getArbHsDecl++instance Arbitrary ArbHsDecl where+    arbitrary = oneof+      [ patternSignature <$> arbitrary <*> arbitrary+      , patternDefinition <$> arbitrary+      ]+    shrink (ArbHsDecl decl) = map ArbHsDecl $ case unLoc decl of+      SigD ext sig -> [fmap (const $ SigD ext smaller) decl | smaller <- shrinkSignature sig]+      ValD ext bind -> [fmap (const $ ValD ext smaller) decl | smaller <- shrinkBinding bind]+      _ -> []+++newtype ConstructorName = ConstructorName String+  deriving Show++instance Arbitrary ConstructorName where+    arbitrary = ConstructorName <$> frequency+      [ (3, elements ["Done", "Yield", "Await"])+      , (1, fixConstructorName <$>+          listOf (elements (['A'..'Z'] ++ ['a'..'z'] ++ ['0'..'9'] ++ "_")))+      ]+    shrink (ConstructorName name) =+      [ ConstructorName smaller+      | smaller <- map fixConstructorName (shrink name)+      , (length smaller, smaller) < (length name, name)+      ]+++fixConstructorName :: String -> String+fixConstructorName name =+    let valid = filter (`elem` (['A'..'Z'] ++ ['a'..'z'] ++ ['0'..'9'] ++ "_")) name+    in case valid of+      first : _ | isUpper first -> valid+      _ -> 'P' : valid+++patternSignature :: ConstructorName -> ConstructorName -> ArbHsDecl+patternSignature (ConstructorName name) (ConstructorName typeName) =+    let readerName = noLocA (mkRdrUnqual $ mkDataOcc name)+        hsType = noLocA (HsTyVar noAnn NotPromoted+                          (noLocA $ mkRdrUnqual $ mkTcOcc typeName))+    in ArbHsDecl $ noLocA $ SigD noExtField $+         PatSynSig noAnn [readerName] (noLocA $ mkHsImplicitSigType hsType)+++patternDefinition :: ConstructorName -> ArbHsDecl+patternDefinition (ConstructorName name) =+    ArbHsDecl $ noLocA $ ValD noExtField $ PatSynBind noExtField PSB+      { psb_ext = noAnn+      , psb_id = noLocA (mkRdrUnqual $ mkDataOcc name)+#if MIN_VERSION_GLASGOW_HASKELL(9,14,0,0)+      , psb_args = PrefixCon []+#else+      , psb_args = PrefixCon [] []+#endif+      , psb_def = noLocA (WildPat noExtField)+      , psb_dir = Unidirectional+      }+++shrinkSignature :: Sig GhcPs -> [Sig GhcPs]+shrinkSignature (PatSynSig ext names sigType) =+     [PatSynSig ext names' sigType | names' <- shrinkList shrinkName names, not (null names')]+  ++ [PatSynSig ext names sigType' | sigType' <- shrinkSignatureType sigType]+shrinkSignature _ = []+++shrinkSignatureType :: LHsSigType GhcPs -> [LHsSigType GhcPs]+shrinkSignatureType (L loc sig@HsSig {sig_body = L bodyLoc (HsTyVar ext promotion name)}) =+    [ L loc sig {sig_body = L bodyLoc (HsTyVar ext promotion smaller)}+    | smaller <- shrinkName name+    ]+shrinkSignatureType _ = []+++shrinkBinding :: HsBind GhcPs -> [HsBind GhcPs]+shrinkBinding (PatSynBind ext psb@PSB {psb_id}) =+    [PatSynBind ext psb {psb_id = smaller} | smaller <- shrinkName psb_id]+shrinkBinding _ = []+++shrinkName :: GenLocated loc RdrName -> [GenLocated loc RdrName]+shrinkName name =+    let occurrence = rdrNameOcc (unLoc name)+    in [ fmap (const $ mkRdrUnqual $ mkOccName (occNameSpace occurrence) smaller) name+       | ConstructorName smaller <- shrink (ConstructorName $ occNameString occurrence)+       ]+++-- Parsed modules keep real source locations, including distinct locations for+-- local bindings which shadow class methods.+newtype ArbClassModule = ArbClassModule { getArbClassModule :: Located (HsModule GhcPs) }++instance Show ArbClassModule where+    show = Out.renderWithContext Out.defaultSDocContext . Out.ppr . getArbClassModule++instance Arbitrary ArbClassModule where+    arbitrary = do+      ConstructorName classSuffix <- arbitrary+      methodSuffixes <- resize 5 arbitrary+      let className = "Class" ++ classSuffix+          methods = ["method" ++ name ++ show i | (i, ConstructorName name) <- zip [0 :: Int ..] methodSuffixes]+      selected <- sublistOf methods+      exports <- elements+        [ ""+        , "()"+        , "(" ++ className ++ "(..))"+        , "(" ++ className ++ "(" ++ intercalate "," selected ++ "))"+        , "(" ++ intercalate "," selected ++ ")"+        , "(" ++ className ++ ")"+        , "(Eq(..))"+        ]+      defaults <- mapM methodBinding methods+      instances <- mapM (\name -> do+        signature <- elements ["", "  " ++ name ++ " :: Int -> ()\n"]+        binding <- methodBinding name+        pure (signature ++ binding)) methods+      pure $ ArbClassModule $ parseModule $+           "module Example " ++ exports ++ " where\n"+        ++ "class " ++ className ++ " a where\n"+        ++ concat ["  " ++ name ++ " :: a -> ()\n" | name <- methods]+        ++ concat defaults+        ++ "instance " ++ className ++ " Int where\n"+        ++ concat instances+    shrink (ArbClassModule ast@(L _ HsModule {hsmodExports})) =+      [ ArbClassModule (fmap (\mod_ -> mod_ {hsmodExports = smaller}) ast)+      | smaller <- case hsmodExports of+          Nothing -> [Just $ noLocA []]+          Just exports ->+            [Just (fmap (const entries) exports) | entries <- shrinkList (const []) (unLoc exports)]+      ]+++methodBinding :: String -> Gen String+methodBinding name = do+    helper <- elements [name, "local" ++ name]+    signature <- elements ["", "      " ++ helper ++ " :: b -> ()\n"]+    pure $ "  " ++ name ++ " x = " ++ helper ++ " x\n"+        ++ "    where\n"+        ++ signature+        ++ "      " ++ helper ++ " _ = ()\n"+++#if !MIN_VERSION_GLASGOW_HASKELL(9,8,0,0)+emptyDiagOpts :: DiagOpts+emptyDiagOpts = DiagOpts+  { diag_warning_flags       = EnumSet.empty+  , diag_fatal_warning_flags = EnumSet.empty+  , diag_warn_is_error       = False+  , diag_reverse_errors      = False+  , diag_max_errors          = Nothing+  , diag_ppr_ctx             = Out.defaultSDocContext+  }+#endif+++parseModule :: String -> Located (HsModule GhcPs)+parseModule source =+    case unP Parser.parseModule (initParserState options (stringToStringBuffer source) location) of+      POk _ ast -> ast+      PFailed _ -> error ("Invalid generated module:\n" ++ source)+  where+    location = mkRealSrcLoc (mkFastString "Example.hs") 1 1+    options = mkParserOpts (EnumSet.fromList [TypeFamilies, ExplicitNamespaces]) emptyDiagOpts+#if !MIN_VERSION_GLASGOW_HASKELL(9,14,0,0)+                []+#endif+                False False False False+++newtype ArbFamilyModule = ArbFamilyModule { getArbFamilyModule :: Located (HsModule GhcPs) }++instance Show ArbFamilyModule where+    show = Out.renderWithContext Out.defaultSDocContext . Out.ppr . getArbFamilyModule++instance Arbitrary ArbFamilyModule where+    arbitrary = do+      ConstructorName suffix <- arbitrary+      familySuffixes <- resize 4 arbitrary+      let className = "Class" ++ suffix+      families <- mapM (\(i, ConstructorName name) -> do+        associated <- arbitrary+        withDefault <- arbitrary+        record <- arbitrary+        rhs <- elements ["Maybe a", "[a]", "(a, a)", "a"]+        let ty = "Type" ++ name ++ show i+            dat = "Data" ++ name ++ show i+            con = "Mk" ++ dat+            field = "field" ++ dat+            declaration = ["type " ++ ty ++ " a", "data " ++ dat ++ " a"]+            def = ["type " ++ ty ++ " a = " ++ rhs | withDefault]+            equations = [ "type " ++ ty ++ " Int = Bool"+                        , "data " ++ dat ++ " Int = " ++ con+                            ++ if record then " { " ++ field ++ " :: Int }" else " Int" ]+            standalone = ["type family " ++ ty ++ " a", "data family " ++ dat ++ " a",+                          "type instance " ++ ty ++ " Int = Bool",+                          "data instance " ++ dat ++ " Int = " ++ con+                            ++ if record then " { " ++ field ++ " :: Int }" else " Int"]+            exports = [ty, "type " ++ ty, dat, dat ++ "(..)", dat ++ "(" ++ con ++ ")"]+                   ++ [field | record]+                   ++ [dat ++ "(" ++ field ++ ")" | record]+                   ++ [className ++ "(" ++ member ++ ")" | associated, member <- [ty, dat]]+        pure (if associated then ([], declaration ++ def, equations, exports)+                            else (standalone, [], [], exports)))+        (zip [0 :: Int ..] familySuffixes)+      exports <- sublistOf ([className, className ++ "(..)", "Eq(..)"]+                        ++ concat [es | (_, _, _, es) <- families])+      exportList <- frequency [(1, pure ""), (4, pure $ "(" ++ intercalate "," exports ++ ")")]+      pure $ ArbFamilyModule $ parseModule $+           "module Example " ++ exportList ++ " where\n"+        ++ unlines (concat [ds | (ds, _, _, _) <- families])+        ++ "class " ++ className ++ " a where\n"+        ++ unlines ["  " ++ d | (_, ds, _, _) <- families, d <- ds]+        ++ "instance " ++ className ++ " Int where\n"+        ++ unlines ["  " ++ d | (_, _, ds, _) <- families, d <- ds]+    shrink (ArbFamilyModule ast@(L _ HsModule {hsmodExports})) =+      [ ArbFamilyModule (fmap (\mod_ -> mod_ {hsmodExports = smaller}) ast)+      | smaller <- case hsmodExports of+          Nothing -> [Just $ noLocA []]+          Just exports ->+            [Just (fmap (const entries) exports) | entries <- shrinkList (const []) (unLoc exports)]+      ]
+ test/Test/Options.hs view
@@ -0,0 +1,47 @@+module Test.Options (tests) where++import Data.Maybe (maybeToList)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.QuickCheck hiding (Success)++import Plugin.GhcTags.Options+++tests :: TestTree+tests = testGroup "Options"+    [ testProperty "filtering defaults on and accepts both disabling flags" filterOptionsProp ]++data FilterFlag = NoArg | LongArg | ShortArg+  deriving (Show, Eq)++instance Arbitrary FilterFlag where+  arbitrary = elements [NoArg, LongArg, ShortArg]+  shrink LongArg  = [ShortArg, NoArg]+  shrink ShortArg = [NoArg]+  shrink NoArg    = []+++filterFlagToFlag :: FilterFlag -> Maybe String+filterFlagToFlag LongArg  = Just "--no-filter"+filterFlagToFlag ShortArg = Just "-F"+filterFlagToFlag NoArg    = Nothing++filterFlagToFilter :: FilterFlag -> Filter+filterFlagToFilter LongArg  = RetainAll+filterFlagToFilter ShortArg = RetainAll+filterFlagToFilter NoArg    = RemoveTerms+++filterOptionsProp :: FilterFlag -> Property+filterOptionsProp flag =+    let args = maybeToList (filterFlagToFlag flag)+    in checkCoverage+       $ cover 10 (flag == NoArg) "default"+       $ cover 10 (flag == LongArg) "long flag"+       $ cover 10 (flag == ShortArg) "short flag"+       $ case runOptionParser args of+           Success options ->+             filterOption options+             ===+             filterFlagToFilter flag+           _ -> counterexample ("Failed to parse " ++ show args) False
+ test/Test/ProjectFile.hs view
@@ -0,0 +1,174 @@+{-# LANGUAGE NamedFieldPuns #-}++module Test.ProjectFile (tests) where++import Control.Exception (bracket)+import Control.Monad (filterM, forM_, when)+import Data.List (find, scanl')+import Data.Maybe (isJust)+import System.Directory+import System.FilePath ((</>), normalise, splitDirectories, takeDirectory, takeExtension)+import System.IO (hClose, openTempFile)++import Test.Tasty (TestTree, testGroup)+import Test.Tasty.QuickCheck++import Plugin.GhcTags.Project+++tests :: TestTree+tests = testGroup "ProjectFile"+    [ testProperty "project-relative tags path matches model" resolutionProp ]+++data ProjectConfig = CabalProject+                   | CabalProjectLocal+                   | StackConfig+  deriving (Eq, Show)++data DirectoryEntry = DirectoryEntry (Maybe ProjectConfig) Bool+  deriving (Eq, Show)++instance Arbitrary DirectoryEntry where+    arbitrary = DirectoryEntry+      <$> frequency+            [ (6, pure Nothing)+            , (1, pure (Just CabalProject))+            , (1, pure (Just CabalProjectLocal))+            , (1, pure (Just StackConfig))+            ]+      <*> arbitrary+    shrink (DirectoryEntry config package) =+         [ DirectoryEntry Nothing package | isJust config ]+      ++ [ DirectoryEntry config False | package ]+++data StackOverride = NoOverride+                   | ExistingOverride+                   | MissingOverride+  deriving (Eq, Show)++instance Arbitrary StackOverride where+    arbitrary = frequency+                  [ (6, pure NoOverride)+                  , (2, pure ExistingOverride)+                  , (1, pure MissingOverride)+                  ]+    shrink NoOverride = []+    shrink _ = [NoOverride]+++data ProjectLayout = ProjectLayout {+    levels         :: NonEmptyList DirectoryEntry,+    absoluteOutput :: Bool,+    stackOverride  :: StackOverride,+    relativeOutput :: FilePath+  }+  deriving Show++instance Arbitrary ProjectLayout where+    arbitrary = ProjectLayout+      <$> resize 6 arbitrary+      <*> frequency+            [ (7, pure False)+            , (1, pure True)+            ]+      <*> arbitrary+      <*> elements ["tags", "TAGS", "output/tags", "../tags"]+    shrink layout@ProjectLayout {levels, absoluteOutput, stackOverride} =+         [ layout { levels = levels' }+         | levels' <- shrink levels ]+      ++ [ layout { absoluteOutput = False }+         | absoluteOutput ]+      ++ [ layout { stackOverride = stackOverride' }+         | stackOverride' <- shrink stackOverride ]+++resolutionProp :: ProjectLayout -> Property+resolutionProp ProjectLayout {levels = NonEmpty entries, absoluteOutput, stackOverride, relativeOutput} =+    ioProperty $ withTestDirectory $ \root -> do+      let components = ["level" ++ show n | n <- [0 .. length entries - 1]]+          directories = drop 1 $ scanl' (</>) root components+          cwd = foldl (</>) root components+          overrideRoot = root </> "override"+          overrideFile = overrideRoot </> "custom-stack.yaml"+          override = case stackOverride of+            NoOverride -> Nothing+            _ -> Just overrideFile+          path = if absoluteOutput then root </> "absolute.tags" else relativeOutput+      createDirectoryIfMissing True cwd+      forM_ (zip directories entries) $ \(dir, DirectoryEntry config package) -> do+        forM_ config $ \kind -> writeFile (dir </> configName kind) ""+        when package $ writeFile (dir </> "package.cabal") ""+      when (stackOverride == ExistingOverride) $ do+        createDirectory overrideRoot+        writeFile overrideFile ""+      -- The resolver can leave the generated hierarchy. Include ambient+      -- directories in the model when no generated project marker stops it.+      ancestors <-+        if   absoluteOutput+          || stackOverride == ExistingOverride+          || any (\(DirectoryEntry config _) -> isJust config) entries+          then pure []+          else readAncestorEntries root+      actual <- resolveTagsFile cwd override path+      let -- Independent model: select the innermost matching directory from+          -- the generated hierarchy followed by its surrounding directories.+          innerFirst = reverse (zip directories entries) ++ ancestors+          project = fst <$> find (\(_, DirectoryEntry config _) -> isJust config) innerFirst+          package = fst <$> find (\(_, DirectoryEntry _ present) -> present) innerFirst++          fallback = maybe cwd id package++          expected :: TagsAtRoot+          expected+            | absoluteOutput+            = (TagsAtProjectRoot path)++            | ExistingOverride <- stackOverride+            = TagsAtProjectRoot (normalise (overrideRoot </> path))++            | Just dir <- project+            = TagsAtProjectRoot (normalise (dir </> path))++            | otherwise+            = TagsAtPackageRoot (normalise (fallback </> path))++      pure $+        classify absoluteOutput "absolute output" $+        classify (stackOverride == ExistingOverride) "explicit Stack configuration" $+        classify (stackOverride == MissingOverride) "missing Stack configuration" $+        classify (not absoluteOutput && stackOverride /= ExistingOverride && not (isJust project)) "fallback" $+          actual === expected+++configName :: ProjectConfig -> FilePath+configName CabalProject      = "cabal.project"+configName CabalProjectLocal = "cabal.project.local"+configName StackConfig       = "stack.yaml"+++-- Snapshot filesystem data only; root selection stays in the property model.+readAncestorEntries :: FilePath -> IO [(FilePath, DirectoryEntry)]+readAncestorEntries root = traverse readEntry parents+  where+    parents = reverse . drop 1 . scanl' (</>) "" . splitDirectories . takeDirectory $ root++    readEntry dir = do+      files <- filterM (doesFileExist . (dir </>)) =<< listDirectory dir+      let config = find ((`elem` files) . configName)+                 $ [CabalProject, CabalProjectLocal, StackConfig]+          package = any ((== ".cabal") . takeExtension) files+      pure (dir, DirectoryEntry config package)+++withTestDirectory :: (FilePath -> IO a) -> IO a+withTestDirectory action = do+    tmp <- getTemporaryDirectory+    bracket (do (path, h) <- openTempFile tmp "ghc-tags-project-test"+                hClose h+                removeFile path+                createDirectory path+                canonicalizePath path)+            removeDirectoryRecursive+            action
+ test/Test/Timing.hs view
@@ -0,0 +1,75 @@+module Test.Timing (tests) where++import Data.IORef+import qualified Data.Map.Strict as Map+import Data.Word (Word16, Word64)+import qualified Pipes+import qualified Pipes.Prelude as Pipes+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.QuickCheck++import Plugin.GhcTags.Timing+++tests :: TestTree+tests = testGroup "Timing"+    [ testProperty "stream phases exclude upstream and downstream work" streamTimingProp+    , testProperty "lock wait excludes the action and release" lockTimingProp+    , testProperty "disabled timing preserves laziness and avoids the clock" disabledTimingProp+    ]+++streamTimingProp :: [(Word16, Word16, Word16, Word16)] -> Property+streamTimingProp steps = ioProperty $ do+    clock <- newIORef 0+    report <- newIORef Nothing+    emitted <- newIORef []+    let advance amount = modifyIORef' clock (+ fromIntegral amount)+    withTimingsUsing (readIORef clock) True (writeIORef report . Just) $ \timings -> do+      let source = timedProducer timings ReadTags $+            mapM_ (\step@(readTime, _, _, _) -> Pipes.liftIO (advance readTime) >> Pipes.yield step) steps+          parsed = timedProducer timings ParseTags $ Pipes.for source $ \step@(_, parseTime, _, _) ->+            Pipes.liftIO (advance parseTime) >> Pipes.yield step+          merged = timedProducer timings MergeTags $ Pipes.for parsed $ \step@(_, _, mergeTime, _) ->+            Pipes.liftIO (advance mergeTime) >> Pipes.yield step+      Pipes.runEffect $ Pipes.for merged $ \step@(_, _, _, writeTime) -> Pipes.liftIO $+        timed timings WriteTags (advance writeTime >> modifyIORef' emitted (step :))+    actual <- readIORef report+    values <- reverse <$> readIORef emitted+    let readTime  = sum [fromIntegral r | (r, _, _, _) <- steps]+        parseTime = sum [fromIntegral p | (_, p, _, _) <- steps]+        mergeTime = sum [fromIntegral m | (_, _, m, _) <- steps]+        writeTime = sum [fromIntegral w | (_, _, _, w) <- steps]+        expected = TimingReport (readTime + parseTime + mergeTime + writeTime) $ Map.fromList+                     [(LockWait, 0), (ReadTags, readTime), (ParseTags, parseTime),+                      (GenerateTags, 0), (MergeTags, mergeTime), (WriteTags, writeTime)]+    pure $ (actual, values) === (Just expected, steps)+++lockTimingProp :: Word16 -> Word16 -> Word16 -> Property+lockTimingProp waitTime actionTime releaseTime = ioProperty $ do+    clock <- newIORef 0+    report <- newIORef Nothing+    let advance amount = modifyIORef' clock (+ fromIntegral amount)+        withLock action = advance waitTime >> action <* advance releaseTime+    withTimingsUsing (readIORef clock) True (writeIORef report . Just) $ \timings ->+      timedLock timings withLock $ timed timings WriteTags $ advance actionTime+    actual <- readIORef report+    let expected = TimingReport (sum $ map fromIntegral [waitTime, actionTime, releaseTime]) $ Map.fromList+                     [(LockWait, fromIntegral waitTime), (ReadTags, 0), (ParseTags, 0),+                      (GenerateTags, 0), (MergeTags, 0), (WriteTags, fromIntegral actionTime)]+    pure $ actual === Just expected+++disabledTimingProp :: [Int] -> Property+disabledTimingProp values = ioProperty $ do+    calls <- newIORef (0 :: Int)+    let forbidden = error "disabled timing used the clock or reported timings"+    result <- withTimingsUsing (forbidden :: IO Word64) False (const forbidden) $ \timings ->+      timedLock timings id $ do+        (items, _) <- timedNF timings GenerateTags $ do+          modifyIORef' calls (+ 1)+          pure (values, error "disabled timing forced the result" :: Int)+        timed timings MergeTags $ Pipes.toListM $ timedProducer timings ParseTags $ Pipes.each items+    count <- readIORef calls+    pure $ (result, count) === (values, 1)
+ test/main.hs view
@@ -0,0 +1,21 @@+module Main (main) where++import Test.Tasty (defaultMain, testGroup)++import qualified Test.AtomicFile+import qualified Test.Check+import qualified Test.Ghc+import qualified Test.Options+import qualified Test.ProjectFile+import qualified Test.Timing+++main :: IO ()+main = defaultMain $ testGroup "ghc-tags-plugin"+    [ Test.AtomicFile.tests+    , Test.Check.tests+    , Test.Ghc.tests+    , Test.Options.tests+    , Test.ProjectFile.tests+    , Test.Timing.tests+    ]