packages feed

synapse-cc-0.2.0: src/SynapseCC/Detect.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Project detection layer for synapse-cc init.
--
-- Inspects the current working directory to infer appropriate config values
-- (transport mode, output directory, etc.) so that generated synapse.config.json
-- is correct out of the box for common project structures.
--
-- Detectors are plain values — extend by prepending to the list passed to
-- 'runDetectors'. Earlier detectors win on a per-field basis.
module SynapseCC.Detect
  ( -- * Types
    ProjectHint(..)
  , Detector(..)
  , emptyHint

    -- * Running detectors
  , runDetectors

    -- * Default detector set
  , defaultDetectors

    -- * Individual detectors (for composition and testing)
  , detectTauri
  , detectVite
  , detectNextJS
  , detectNodeProject
  ) where

import Control.Applicative ((<|>))
import Control.Monad (foldM)
import qualified Data.Text as T
import System.Directory (doesDirectoryExist, doesFileExist)

import SynapseCC.Types (TransportType(..))

-- ============================================================================
-- Hint type
-- ============================================================================

-- | Inferred preferences for a new synapse.config.json.
-- 'Nothing' means "no opinion" — another detector or the hardcoded default wins.
data ProjectHint = ProjectHint
  { phTransport :: Maybe TransportType
    -- ^ 'WsTransport' (Node.js) or 'BrowserTransport' (native WebSocket)
  , phReason    :: Maybe T.Text
    -- ^ Human-readable explanation of why transport was chosen
  } deriving (Show, Eq)

-- | Hint with no opinions on any field.
emptyHint :: ProjectHint
emptyHint = ProjectHint Nothing Nothing

-- | Merge two hints: earlier/left values win per field.
-- Uses 'Control.Applicative.<|>' which picks the first 'Just'.
mergeHints :: ProjectHint -> ProjectHint -> ProjectHint
mergeHints a b = ProjectHint
  { phTransport = phTransport a <|> phTransport b
  , phReason    = phReason a    <|> phReason b
  }

-- ============================================================================
-- Detector type
-- ============================================================================

-- | A named detector that inspects the project and returns a hint.
--
-- Detectors are run in order; the first non-Nothing value wins per field.
-- Add custom detectors by prepending them to the list passed to 'runDetectors'.
data Detector = Detector
  { detectorName :: T.Text
    -- ^ Short label for debug output (e.g. "tauri", "vite")
  , runDetector  :: IO ProjectHint
    -- ^ IO action: inspect CWD, return hints (or 'emptyHint' if not applicable)
  }

-- ============================================================================
-- Running detectors
-- ============================================================================

-- | Run all detectors in order, merging results.
-- The first non-Nothing value per field wins (leftmost detector takes priority).
runDetectors :: [Detector] -> IO ProjectHint
runDetectors = foldM step emptyHint
  where
    step acc det = mergeHints acc <$> runDetector det

-- ============================================================================
-- Default detector set
-- ============================================================================

-- | Default detector list, in priority order:
--
-- @
-- detectTauri      -- src-tauri/          → browser
-- detectVite       -- vite.config.*       → browser
-- detectNextJS     -- next.config.*       → browser
-- detectNodeProject-- package.json        → ws (fallback)
-- @
--
-- Inject additional detectors by prepending to this list:
--
-- @
-- myDetector : defaultDetectors
-- @
defaultDetectors :: [Detector]
defaultDetectors =
  [ detectTauri
  , detectVite
  , detectNextJS
  , detectNodeProject
  ]

-- ============================================================================
-- Individual detectors
-- ============================================================================

-- | Tauri desktop-app projects.
-- Looks for 'src-tauri/' directory or 'tauri.conf.json' (v1 or v2).
-- Sets transport to 'BrowserTransport' — Tauri's WebView exposes a native
-- 'WebSocket' global, so the @ws@ npm package must not be imported.
detectTauri :: Detector
detectTauri = Detector "tauri" $ do
  hasSrcTauri  <- doesDirectoryExist "src-tauri"
  hasTauriConf <- anyExist
    [ "tauri.conf.json"
    , "src-tauri/tauri.conf.json"
    , "src-tauri/tauri.conf.json5"
    ]
  if hasSrcTauri || hasTauriConf
    then pure emptyHint
      { phTransport = Just BrowserTransport
      , phReason    = Just "Tauri project detected (src-tauri/) — using browser WebSocket"
      }
    else pure emptyHint

-- | Vite projects (browser-native bundler context).
-- Looks for 'vite.config.ts', 'vite.config.js', or 'vite.config.mts'.
-- Sets transport to 'BrowserTransport'.
detectVite :: Detector
detectVite = Detector "vite" $ do
  found <- anyExist
    [ "vite.config.ts"
    , "vite.config.js"
    , "vite.config.mts"
    , "vite.config.mjs"
    ]
  if found
    then pure emptyHint
      { phTransport = Just BrowserTransport
      , phReason    = Just "Vite project detected — using browser WebSocket"
      }
    else pure emptyHint

-- | Next.js projects.
-- Looks for 'next.config.js', 'next.config.ts', or 'next.config.mjs'.
-- Sets transport to 'BrowserTransport' (client components use native WebSocket).
detectNextJS :: Detector
detectNextJS = Detector "nextjs" $ do
  found <- anyExist
    [ "next.config.js"
    , "next.config.ts"
    , "next.config.mjs"
    ]
  if found
    then pure emptyHint
      { phTransport = Just BrowserTransport
      , phReason    = Just "Next.js project detected — using browser WebSocket"
      }
    else pure emptyHint

-- | Bare Node.js projects (package.json present, no framework marker).
-- Sets transport to 'WsTransport' — the @ws@ npm package is available in Node.
-- This is the fallback: runs last, so framework detectors override it.
detectNodeProject :: Detector
detectNodeProject = Detector "node" $ do
  found <- doesFileExist "package.json"
  if found
    then pure emptyHint
      { phTransport = Just WsTransport
      , phReason    = Just "Node.js project detected — using ws WebSocket"
      }
    else pure emptyHint

-- ============================================================================
-- Helpers
-- ============================================================================

-- | Return True if any of the given paths exists as a file.
anyExist :: [FilePath] -> IO Bool
anyExist paths = foldM step False paths
  where
    step True  _    = pure True
    step False path = doesFileExist path