/ nix / script / exe / combine-archive.hs
combine-archive.hs
  1  {-# LANGUAGE LambdaCase #-}
  2  {-# LANGUAGE OverloadedStrings #-}
  3  
  4  {- |
  5  Module      : Main
  6  Description : Combine Abseil's ~130 static libraries into one
  7  
  8  This script replaces combine-archive.sh with typed Haskell.
  9  It parses pkg-config files, builds a dependency graph,
 10  topologically sorts using Kahn's algorithm, and combines
 11  archives using ar.
 12  
 13  Usage: combine-archive <output-dir> [ar-prefix]
 14  -}
 15  module Main where
 16  
 17  import Aleph.Script hiding (length)
 18  import qualified Aleph.Script as W
 19  import Data.List (sort)
 20  import qualified Data.Map.Strict as Map
 21  import qualified Data.Set as Set
 22  import qualified Data.Text as T
 23  import System.Environment (getArgs)
 24  import Prelude hiding (FilePath)
 25  
 26  -- | Dependency graph: library name -> set of dependencies
 27  type DepsGraph = Map.Map Text (Set.Set Text)
 28  
 29  -- | In-degree map for Kahn's algorithm
 30  type InDegree = Map.Map Text Int
 31  
 32  main :: IO ()
 33  main = script $ do
 34      args <- liftIO getArgs
 35      case args of
 36          [outDir] -> combineArchive outDir ""
 37          [outDir, arPrefix] -> combineArchive outDir arPrefix
 38          _ -> die "Usage: combine-archive <output-dir> [ar-prefix]"
 39  
 40  combineArchive :: FilePath -> String -> Sh ()
 41  combineArchive outDir arPrefix = do
 42      let libDir = outDir </> "lib"
 43          pkgconfigDir = libDir </> "pkgconfig"
 44          ar = arPrefix <> "ar"
 45  
 46      cd libDir
 47  
 48      echo "// extracting dependencies from pkg-config files..."
 49  
 50      -- Find all absl_*.pc files
 51      pcFiles <- findPkgConfigFiles pkgconfigDir
 52  
 53      -- Parse dependencies and build graph
 54      (graph, allLibs, privateDeps) <- buildDepsGraph pcFiles
 55  
 56      echo $ "// found " <> pack (show (Set.size allLibs)) <> " libraries"
 57  
 58      -- Topologically sort libraries
 59      sortedLibs <- kahnSort graph allLibs
 60  
 61      -- Verify we got all libraries
 62      actualLibs <- findAbseilArchives
 63      let sortedCount = length sortedLibs
 64          actualCount = length actualLibs
 65  
 66      libs <-
 67          if sortedCount /= actualCount
 68              then do
 69                  echoErr $
 70                      "// warning: dependency sort found "
 71                          <> pack (show sortedCount)
 72                          <> " libraries but "
 73                          <> pack (show actualCount)
 74                          <> " exist"
 75                  echoErr "// falling back to simple sort"
 76                  pure $ sort actualLibs
 77              else pure sortedLibs
 78  
 79      -- Write manifest for debugging
 80      writeManifest libs
 81  
 82      -- Create ar script and combine archives
 83      createCombinedArchive ar libs
 84  
 85      -- Verify output
 86      exists <- test_f "libabseil.a"
 87      unless exists $ die "libabseil.a was not created"
 88  
 89      -- Cleanup individual archives
 90      forM_ actualLibs rm
 91  
 92      -- Remove individual pkg-config files
 93      forM_ pcFiles $ \pc -> do
 94          let name = basename pc
 95          when ("absl_" `isPrefixOf` pack name) $ rm pc
 96  
 97      -- Generate combined pkg-config file
 98      generatePkgConfig outDir privateDeps
 99  
100      -- Report success
101      size <- getFileSize "libabseil.a"
102      echo $ "// created libabseil.a (" <> formatSize size <> ")"
103      echo $ "// dependencies: " <> T.unwords (Set.toList privateDeps)
104      echo "// success: archive ready for hypermodern computing"
105  
106  -- | Find all absl_*.pc files in pkgconfig directory
107  findPkgConfigFiles :: FilePath -> Sh [FilePath]
108  findPkgConfigFiles dir = do
109      exists <- test_d dir
110      if exists
111          then do
112              files <- ls dir
113              pure $
114                  filter
115                      ( \f ->
116                          "absl_" `isPrefixOf` pack (takeFileName f)
117                              && ".pc" `isSuffixOf` pack f
118                      )
119                      files
120          else pure []
121  
122  -- | Find all libabsl_*.a files in current directory
123  findAbseilArchives :: Sh [FilePath]
124  findAbseilArchives = do
125      files <- ls "."
126      -- Use takeFileName to handle paths like "./libabsl_foo.a"
127      pure $
128          filter
129              ( \f ->
130                  "libabsl_" `isPrefixOf` pack (takeFileName f)
131                      && ".a" `isSuffixOf` pack f
132              )
133              files
134  
135  -- | Parse pkg-config files and build dependency graph
136  buildDepsGraph :: [FilePath] -> Sh (DepsGraph, Set.Set Text, Set.Set Text)
137  buildDepsGraph pcFiles = do
138      -- Collect private dependencies (pthread, m, rt, dl, etc.)
139      privateDeps <- collectPrivateDeps pcFiles
140  
141      -- Build dependency graph
142      results <- forM pcFiles $ \pc -> do
143          let libName = pack $ dropExtension $ takeFileName pc
144              libFile = "lib" <> unpack libName <> ".a"
145  
146          exists <- test_f libFile
147          if exists
148              then do
149                  deps <- extractAbslDeps pc
150                  pure $ Just (libName, deps)
151              else pure Nothing
152  
153      let validResults = catMaybes results
154          graph = Map.fromList validResults
155          allLibs = Set.fromList $ map fst validResults
156  
157      pure (graph, allLibs, privateDeps)
158  
159  -- | Extract Libs.private dependencies from all pkg-config files
160  collectPrivateDeps :: [FilePath] -> Sh (Set.Set Text)
161  collectPrivateDeps pcFiles = do
162      deps <- forM pcFiles $ \pc -> do
163          content <- liftIO $ Prelude.readFile pc
164          pure $ extractPrivateLibs (pack content)
165  
166      -- Always include common system libraries
167      let baseDeps = Set.fromList ["pthread", "m", "rt", "dl"]
168      pure $ Set.union baseDeps (Set.unions deps)
169  
170  -- | Extract -l flags from Libs.private line
171  extractPrivateLibs :: Text -> Set.Set Text
172  extractPrivateLibs content =
173      let contentLines = T.lines content
174          privLines = filter ("Libs.private:" `isInfixOf`) contentLines
175          tokens = concatMap T.words privLines
176          libs = mapMaybe extractLib tokens
177       in Set.fromList libs
178    where
179      extractLib t
180          | "-l" `isPrefixOf` t = Just (T.drop 2 t)
181          | otherwise = Nothing
182  
183  -- | Extract absl_* dependencies from Requires fields
184  extractAbslDeps :: FilePath -> Sh (Set.Set Text)
185  extractAbslDeps pc = do
186      content <- liftIO $ Prelude.readFile pc
187      let contentLines = T.lines (pack content)
188          reqLines =
189              filter
190                  ( \l ->
191                      "Requires:" `isPrefixOf` l
192                          || "Requires.private:" `isPrefixOf` l
193                  )
194                  contentLines
195          tokens = concatMap (T.words . snd . breakOn ":") reqLines
196          -- Remove commas and filter for absl_* names
197          cleaned = map (W.replace "," "") tokens
198          abslDeps = filter ("absl_" `isPrefixOf`) cleaned
199      pure $ Set.fromList abslDeps
200  
201  -- | Kahn's algorithm for topological sort
202  kahnSort :: DepsGraph -> Set.Set Text -> Sh [FilePath]
203  kahnSort graph allLibs = do
204      let
205          -- Initialize in-degrees to 0
206          initDegrees :: InDegree
207          initDegrees = Map.fromList [(lib, 0) | lib <- Set.toList allLibs]
208  
209          -- Calculate in-degrees: for each lib, increment degree of its deps
210          inDegrees = Map.foldrWithKey countDeps initDegrees graph
211  
212          countDeps _lib deps acc =
213              Set.foldr
214                  ( \dep m ->
215                      if Set.member dep allLibs
216                          then Map.adjust (+ 1) dep m
217                          else m
218                  )
219                  acc
220                  deps
221  
222          -- Initial queue: nodes with in-degree 0
223          initialQueue = [lib | (lib, deg) <- Map.toList inDegrees, deg == 0]
224  
225      go inDegrees (sort initialQueue) []
226    where
227      go _ [] sorted = pure $ reverse $ map toArchive sorted
228      go degrees (current : rest) sorted = do
229          let sorted' = current : sorted
230  
231              -- Find all nodes that depend on current
232              -- and decrement their in-degree
233              (degrees', newReady) =
234                  Map.foldrWithKey
235                      ( \lib deps (d, ready) ->
236                          if Set.member current deps
237                              then
238                                  let newDeg = Map.findWithDefault 0 lib d - 1
239                                      d' = Map.insert lib newDeg d
240                                      ready' =
241                                          if newDeg == 0
242                                              then lib : ready
243                                              else ready
244                                   in (d', ready')
245                              else (d, ready)
246                      )
247                      (degrees, [])
248                      graph
249  
250              -- Add newly ready nodes to queue (sorted for determinism)
251              queue' = sort (rest ++ newReady)
252  
253          go degrees' queue' sorted'
254  
255      toArchive libName = "lib" <> unpack libName <> ".a"
256  
257  -- | Write manifest file for debugging
258  writeManifest :: [FilePath] -> Sh ()
259  writeManifest libs = do
260      cwd <- pwd
261      let content = T.unlines $ ["Libraries combined:"] ++ map pack libs
262      liftIO $ Prelude.writeFile (cwd </> "libabseil.manifest") (unpack content)
263  
264  -- | Create combined archive using ar -M script
265  createCombinedArchive :: String -> [FilePath] -> Sh ()
266  createCombinedArchive ar libs = do
267      -- Get current directory (we need absolute paths for ar)
268      cwd <- pwd
269  
270      -- Write ar script with absolute paths
271      let arScript =
272              T.unlines $
273                  ["CREATE " <> pack cwd <> "/libabseil.a"]
274                      ++ ["ADDLIB " <> pack cwd <> "/" <> pack lib | lib <- libs]
275                      ++ ["SAVE", "END"]
276  
277      -- Write to /tmp since nix store paths may be read-only during fixup
278      let arScriptPath = "/tmp/combine-archive.ar"
279      liftIO $ Prelude.writeFile arScriptPath (unpack arScript)
280  
281      -- Run ar -M with stdin from file
282      _ <- errExit False $ bash $ pack ar <> " -M < " <> pack arScriptPath
283      code <- exitCode
284  
285      when (code /= 0) $ die "failed to combine archives"
286  
287  -- | Generate combined pkg-config file
288  generatePkgConfig :: FilePath -> Set.Set Text -> Sh ()
289  generatePkgConfig outDir privateDeps = do
290      let privateLibs = T.unwords ["-l" <> dep | dep <- sort $ Set.toList privateDeps]
291          content =
292              T.unlines
293                  [ "prefix=" <> pack outDir
294                  , "exec_prefix=${prefix}"
295                  , "libdir=${prefix}/lib"
296                  , "includedir=${prefix}/include"
297                  , ""
298                  , "Name: libabseil"
299                  , "Description: Abseil C++ libraries (libmodern combined archive)"
300                  , "Version: 20250127.1"
301                  , "URL: https://abseil.io/"
302                  , "Libs: -L${libdir} -labseil"
303                  , "Libs.private: -L${libdir} " <> privateLibs
304                  , "Cflags: -I${includedir}"
305                  ]
306  
307      -- Ensure pkgconfig directory exists (we may have deleted all files in it)
308      -- Use absolute path since Prelude.writeFile doesn't respect Shelly's cd
309      cwd <- pwd
310      mkdirP "pkgconfig"
311      liftIO $ Prelude.writeFile (cwd </> "pkgconfig" </> "abseil.pc") (unpack content)
312  
313  -- | Get file size
314  getFileSize :: FilePath -> Sh Integer
315  getFileSize path = do
316      output <- run "stat" ["-c", "%s", pack path]
317      pure $ read $ unpack $ strip output
318  
319  -- | Format file size for display
320  formatSize :: Integer -> Text
321  formatSize bytes
322      | bytes >= 1024 * 1024 = pack (show (bytes `div` (1024 * 1024))) <> "MB"
323      | bytes >= 1024 = pack (show (bytes `div` 1024)) <> "KB"
324      | otherwise = pack (show bytes) <> "B"