packages feed

convex-schema-parser-0.1.3.0: app/Dev.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}

module Dev (runDevMode, DevOptions (..)) where

import qualified Backend.Python as Python
import qualified Backend.Python.Validator as Python.Validator
import qualified Backend.Rust as Rust
import qualified Backend.Rust.Validator as Rust.Validator
import qualified Config
import Control.Concurrent (forkIO, threadDelay)
import Control.Concurrent.STM (TVar, atomically, newTVarIO, readTVar, retry, writeTVar)
import Control.DeepSeq (rnf)
import Control.Exception
import Control.Exception (evaluate)
import Control.Monad (forM_, forever, when)
import Control.Monad.IO.Class (MonadIO, liftIO)
import qualified Convex.Parser as P
import qualified Convex.Validator as Validator
import System.Directory (doesFileExist, findExecutable)
import System.Exit (ExitCode (..), exitFailure)
import System.FSNotify
import System.IO.Error (isAlreadyInUseError)
import System.Process (CreateProcess (..), StdStream (..), createProcess, proc, waitForProcess)

data DevOptions = DevOptions
  { convexProjectPath :: FilePath,
    declarationsDir :: FilePath,
    config :: Config.Config
  }

-- | Searches for an available package manager, preferring pnpm.
findPackageManager :: IO (Maybe String)
findPackageManager = do
  pnpmPath <- findExecutable "pnpm"
  case pnpmPath of
    Just _ -> return (Just "pnpm")
    Nothing -> do
      npmPath <- findExecutable "npm"
      case npmPath of
        Just _ -> return (Just "npm")
        Nothing -> return Nothing

-- | Runs the development mode file watcher with debouncing.
runDevMode :: Config.Config -> IO ()
runDevMode config = do
  -- TVar to signal that a change has occurred.
  dirtyVar <- newTVarIO True -- Start dirty to trigger an initial run.

  -- Fork a worker thread that handles the generation.
  _ <- forkIO (workerThread dirtyVar config)

  -- The main thread will watch for file changes.
  withManager $ \mgr -> do
    putStrLn $ "Watching for changes in: " ++ convexPath
    watchTree
      mgr
      convexPath
      (const True) -- Watch all events
      ( \event -> do
          putStrLn $ "[Watcher] Detected change: " ++ showEvent event
          -- Atomically set the dirty flag. This is fast and non-blocking.
          atomically $ writeTVar dirtyVar True
      )

    -- Keep the main thread alive.
    forever $ threadDelay 1000000
  where
    convexPath = Config.projectPath config ++ "/convex"
    showEvent (Added path _ _) = "Added: " ++ path
    showEvent (Modified path _ _) = "Modified: " ++ path
    showEvent (Removed path _ _) = "Removed: " ++ path
    showEvent _ = "Unknown event"

-- | The worker thread loop that performs debouncing.
workerThread :: TVar Bool -> Config.Config -> IO ()
workerThread dirtyVar config = forever $ do
  -- Wait for a signal that something has changed.
  atomically $ do
    dirty <- readTVar dirtyVar
    -- The `check` function will retry the transaction if the condition is false.
    -- This effectively makes the thread sleep until `dirty` is True.
    when (not dirty) retry

  -- Once signaled, reset the flag and wait for the debounce period.
  atomically $ writeTVar dirtyVar False
  putStrLn "[Worker] Change detected. Debouncing for 300ms..."
  threadDelay 300000 -- 300ms debounce period.

  -- After debouncing, check if another change came in. If not, generate.
  isDirty <- atomically (readTVar dirtyVar)
  if isDirty
    then putStrLn "[Worker] Further changes detected. Restarting debounce timer."
    else
      handleGenerationEvent config `catch` \(e :: SomeException) -> do
        putStrLn $ "[Worker] Error during generation: " ++ show e

-- | Handles the logic for regenerating declarations and the clients.
handleGenerationEvent :: Config.Config -> IO ()
handleGenerationEvent config = do
  findPackageManager >>= \case
    Nothing -> putStrLn "\n[ERROR] Neither 'pnpm' nor 'npm' could be found in your system's PATH.\nPlease install one of them to use the dev watcher.\n"
    Just pm -> do
      putStrLn "[Worker] Regenerating .d.ts files..."
      let pkgMgrCmd = (proc pm ["declarations"]) {cwd = Just $ Config.projectPath config}
      (_, _, _, pkgMgrHandle) <- createProcess pkgMgrCmd
      exitCode <- waitForProcess pkgMgrHandle
      case exitCode of
        ExitSuccess -> do
          putStrLn "[Worker] Declaration files regenerated successfully."
          generateAndWriteCode config
        ExitFailure code ->
          putStrLn $ "[Worker] Error: 'pnpm declarations' failed with exit code " ++ show code

-- | Generates the code and writes it to the target files if it has changed.
generateAndWriteCode :: Config.Config -> IO ()
generateAndWriteCode config = do
  putStrLn "[Worker] Parsing schema and generating clients..."
  projectResult <- P.parseProject (Config.projectPath config ++ "/convex/schema.ts") $ Config.declarationsDir config
  case projectResult of
    Left err ->
      putStrLn $ "[Worker] Error parsing project: " ++ err
    Right project ->
      -- Iterate over each target defined in the config
      forM_ (Config.targetList config) $ \Config.TargetConfig {..} -> do
        putStrLn $ "[Worker] Generating for target: " ++ show lang
        let generatedCode = case lang of
              Config.Python -> Python.generatePythonCode project
              Config.Rust -> Rust.generateRustCode project

        let validatorAction :: (MonadFail m, MonadIO m, Validator.Validator m) => m String
            validatorAction = do
              Validator.setup
              Validator.validate generatedCode >>= \case
                Just formattedAndChecked -> do
                  liftIO $ putStrLn "[Worker] Rust code validation successful."
                  return formattedAndChecked
                Nothing -> do
                  fail "[Worker] Rust code validation failed. Check the output above."

        checkedCode <- case lang of
          Config.Rust -> do
            Rust.Validator.run (Rust.Validator.RustValidatorEnv (Config.validationPath config ++ "/rust_validation_project")) validatorAction
          Config.Python -> do
            -- Python.Validator.run (Python.Validator.PythonValidatorEnv (Config.projectPath config ++ "/python_validation_project")) validatorAction
            return generatedCode

        -- Write to each output file defined for the target
        forM_ output $ \path -> do
          !oldContentResult <- readTargetFileWithRetry path
          putStrLn $ "[Worker] Checking " ++ path ++ " for changes..."
          case oldContentResult of
            Left err -> putStrLn $ "[Worker] Error reading existing file: " ++ show err
            Right oldContent ->
              if checkedCode /= oldContent
                then do
                  putStrLn $ "[Worker]   -> Changes detected for " ++ path
                  putStrLn "[Worker]   -> Writing new content..."
                  writeResult <- writeFileWithRetry path checkedCode
                  case writeResult of
                    Left err -> putStrLn $ "[Worker] Error writing file: " ++ show err
                    Right () -> putStrLn $ "[Worker]   -> Success! Updated " ++ path
                else
                  putStrLn $ "[Worker]   -> No changes detected for " ++ path

-- | Safely reads the content of a file, retrying on busy errors.
readTargetFileWithRetry :: FilePath -> IO (Either IOException String)
readTargetFileWithRetry path = do
  exists <- doesFileExist path
  if exists
    then tryWithRetries 10 200000 $ do
      -- 10 retries, 200ms apart
      content <- readFile path
      _ <- evaluate (rnf content) -- Force evaluation to ensure the file handle is released.
      return content
    else return (Right "")

-- | Writes content to a file, retrying on busy errors.
writeFileWithRetry :: FilePath -> String -> IO (Either IOException ())
writeFileWithRetry path content = tryWithRetries 10 200000 (writeFile path content)

-- | A helper to retry an IO action if it fails due to a "resource busy" error.
tryWithRetries :: Int -> Int -> IO a -> IO (Either IOException a)
tryWithRetries 0 _ action = try action -- Last attempt, return the result
tryWithRetries n delayMicroseconds action = do
  result <- try action
  case result of
    Left e | isAlreadyInUseError e -> do
      putStrLn $ "[Retry] File is busy, retrying in " ++ show (delayMicroseconds `div` 1000) ++ "ms..."
      threadDelay delayMicroseconds
      tryWithRetries (n - 1) delayMicroseconds action
    _ -> return result