From 032b47c27dec82b94c535bdfb124133ae87450f4 Mon Sep 17 00:00:00 2001 From: Jeremie Date: Mon, 20 Apr 2026 14:31:42 +0200 Subject: [PATCH 1/3] Add external configuration system with LSP registry, Nix/JSON LSP support - New Domain.Config module: LSPServerConfig, GraphosConfig types with YAML serialization - New Infrastructure.Config module: YAML loader (graphos.yaml), config-driven LSP server resolution - Refactor Client.hs: add .nix/.json to hardcoded LSP server registry, document config-driven path - Refactor Protocol.hs: add .nix/.json/.text/.raml language ID mappings - Refactor Detect.hs: add .nix/.json to code extensions, .text/.raml to doc extensions, add detectFilesWithExtensions for config-driven detection - Add GraphosConfig field to PipelineConfig for future config file integration - Add graphos.yaml default configuration file with all LSP servers and file extensions - Update shell.nix: add nixd and vscode-langservers-extracted - 75/75 tests pass, cabal build clean --- app/Main.hs | 2 + graphos.cabal | 12 +- graphos.yaml | 142 +++++++++++++++++ shell.nix | 10 +- src/Graphos/Domain/Config.hs | 176 +++++++++++++++++++++ src/Graphos/Domain/Types.hs | 3 + src/Graphos/Infrastructure/Config.hs | 117 ++++++++++++++ src/Graphos/Infrastructure/LSP/Client.hs | 11 +- src/Graphos/Infrastructure/LSP/Protocol.hs | 61 +++---- src/Graphos/UseCase/Detect.hs | 70 ++++++-- 10 files changed, 557 insertions(+), 47 deletions(-) create mode 100644 graphos.yaml create mode 100644 src/Graphos/Domain/Config.hs create mode 100644 src/Graphos/Infrastructure/Config.hs diff --git a/app/Main.hs b/app/Main.hs index ebf4799..007d45e 100644 --- a/app/Main.hs +++ b/app/Main.hs @@ -17,6 +17,7 @@ import Graphos.Infrastructure.Logging (LogLevel(..), defaultLogEnv, logInfo, log import Graphos.Infrastructure.Server.Static (startStaticServer) import Graphos.Infrastructure.Server.MCP (startMCPServerFromFile) +import Graphos.Domain.Config (defaultGraphosConfig) import qualified Data.Map.Strict as Map import qualified Data.Set as Set import Data.Maybe (listToMaybe) @@ -59,6 +60,7 @@ pipelineOpts = PipelineConfig <*> option auto (long "min-comm-size" <> value 3 <> help "Minimum community size; smaller get merged (default: 3)") <*> option auto (long "threads" <> short 'j' <> value 1 <> help "Number of parallel extraction threads (default: 1)") <*> switch (long "community-graph" <> help "Export community-level graph JSON for LLM navigation") + <*> pure defaultGraphosConfig -- placeholder; loaded from graphos.yaml at runtime queryOpts :: Parser Command queryOpts = QueryCmd diff --git a/graphos.cabal b/graphos.cabal index 3499a0f..0d3922d 100644 --- a/graphos.cabal +++ b/graphos.cabal @@ -71,10 +71,12 @@ library Graphos.UseCase.SelectContext Graphos.UseCase.FormatContext Graphos.UseCase.Conversation - -- Infrastructure - LSP - Graphos.Infrastructure.LSP.Client - Graphos.Infrastructure.LSP.Protocol - Graphos.Infrastructure.LSP.Capabilities + -- Infrastructure - LSP + Graphos.Infrastructure.LSP.Client + Graphos.Infrastructure.LSP.Protocol + Graphos.Infrastructure.LSP.Capabilities + -- Infrastructure - Config + Graphos.Infrastructure.Config -- Infrastructure - FileSystem Graphos.Infrastructure.FileSystem.Watcher Graphos.Infrastructure.FileSystem.Cache @@ -107,6 +109,8 @@ library Graphos.Domain.Community.Label -- Domain - Context optimization Graphos.Domain.Context + -- Domain - Config + Graphos.Domain.Config other-modules: Paths_graphos diff --git a/graphos.yaml b/graphos.yaml new file mode 100644 index 0000000..97b7ded --- /dev/null +++ b/graphos.yaml @@ -0,0 +1,142 @@ +# ─────────────────────────────────────────────── +# Graphos Configuration +# ─────────────────────────────────────────────── +# This file configures LSP servers and file extension categories. +# Any field left out will fall back to built-in defaults. +# User values override defaults (for LSP/language_ids maps). + +# ──── LSP Servers ───────────────────────────── +# Map file extension → {command, args, language_id} +# Set command to "" to explicitly disable an extension's LSP. +# Unlisted extensions use defaults from Graphos.Domain.Config. +lsp: + ".nix": + command: nixd + args: [] + language_id: nix + ".json": + command: vscode-json-language-server + args: ["--stdio"] + language_id: json + ".hs": + command: haskell-language-server + args: ["--lsp"] + language_id: haskell + ".py": + command: pyright-langserver + args: ["--stdio"] + language_id: python + ".ts": + command: typescript-language-server + args: ["--stdio"] + language_id: typescript + ".tsx": + command: typescript-language-server + args: ["--stdio"] + language_id: typescriptreact + ".js": + command: typescript-language-server + args: ["--stdio"] + language_id: javascript + ".go": + command: gopls + args: [] + language_id: go + ".rs": + command: rust-analyzer + args: [] + language_id: rust + ".c": + command: clangd + args: [] + language_id: c + ".cpp": + command: clangd + args: [] + language_id: cpp + # Disable LSP for .text files (no server available) + ".text": + command: "" + args: [] + language_id: plaintext + # RAML — no LSP available, treat as document + ".raml": + command: "" + args: [] + language_id: raml + +# ──── Language IDs ───────────────────────────── +# Override or add language IDs for file extensions. +# Unlisted extensions use defaults. +language_ids: + ".nix": nix + ".json": json + ".text": plaintext + ".raml": raml + +# ──── File Extension Categories ──────────────── +# Full override for each category (replaces defaults). +# If you only want to ADD extensions, copy the defaults here and append. +file_extensions: + code: + - .py + - .ts + - .tsx + - .js + - .jsx + - .go + - .rs + - .java + - .c + - .cpp + - .h + - .hpp + - .rb + - .cs + - .kt + - .kts + - .scala + - .php + - .swift + - .lua + - .zig + - .hs + - .lhs + - .ex + - .exs + - .m + - .mm + - .jl + - .vue + - .svelte + - .dart + - .ps1 + - .nix # NEW: Nix expressions + - .json # NEW: JSON documents (symbols via LSP) + doc: + - .md + - .txt + - .rst + - .adoc + - .org + - .text # NEW: plain text documents + - .raml # NEW: RAML API specs (treated as docs) + paper: + - .pdf + image: + - .png + - .jpg + - .jpeg + - .webp + - .gif + video: + - .mp4 + - .mov + - .mkv + - .webm + - .avi + - .m4v + - .mp3 + - .wav + - .m4a + - .ogg \ No newline at end of file diff --git a/shell.nix b/shell.nix index 97c3067..cc122e4 100644 --- a/shell.nix +++ b/shell.nix @@ -16,7 +16,15 @@ let systemDeps = with pkgs'; [ zlib openssl ]; - tooling = with pkgs'; [ jq pyright python313Packages.pyyaml bun uv ]; + tooling = with pkgs'; [ + jq + pyright + python313Packages.pyyaml + bun + uv + nixd + vscode-langservers-extracted + ]; libPaths = pkgs'.lib.makeLibraryPath systemDeps; diff --git a/src/Graphos/Domain/Config.hs b/src/Graphos/Domain/Config.hs new file mode 100644 index 0000000..178ef91 --- /dev/null +++ b/src/Graphos/Domain/Config.hs @@ -0,0 +1,176 @@ +-- | Domain configuration types for Graphos. +-- Pure data types — no IO. Config file loading lives in Infrastructure. +{-# LANGUAGE DeriveGeneric #-} +module Graphos.Domain.Config + ( -- * LSP configuration + LSPServerConfig(..) + , defaultLSPServers + , defaultLanguageIds + + -- * File extension configuration + , FileExtensionConfig(..) + , defaultFileExtensions + + -- * Top-level configuration + , GraphosConfig(..) + , defaultGraphosConfig + ) where + +import Data.Aeson (ToJSON(..), FromJSON(..), genericParseJSON, genericToJSON) +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as Map +import Data.Text (Text) +import GHC.Generics (Generic) +import Data.Aeson.Types (defaultOptions, fieldLabelModifier) + +-- ─────────────────────────────────────────────── +-- LSP Server Configuration +-- ─────────────────────────────────────────────── + +-- | Configuration for a single LSP server. +-- Maps a file extension to a language server command. +data LSPServerConfig = LSPServerConfig + { lspCommand :: String + , lspArgs :: [String] + , lspLanguageId :: Text + } deriving (Eq, Show, Generic) + +instance ToJSON LSPServerConfig where + toJSON = genericToJSON defaultOptions { fieldLabelModifier = drop 3 } + +instance FromJSON LSPServerConfig where + parseJSON = genericParseJSON defaultOptions { fieldLabelModifier = drop 3 } + +-- | Default LSP server configurations (hardcoded fallback). +-- Users can override these via graphos.yaml. +defaultLSPServers :: Map String LSPServerConfig +defaultLSPServers = Map.fromList + [ (".ts", LSPServerConfig "typescript-language-server" ["--stdio"] "typescript") + , (".tsx", LSPServerConfig "typescript-language-server" ["--stdio"] "typescriptreact") + , (".js", LSPServerConfig "typescript-language-server" ["--stdio"] "javascript") + , (".jsx", LSPServerConfig "typescript-language-server" ["--stdio"] "javascriptreact") + , (".py", LSPServerConfig "pyright-langserver" ["--stdio"] "python") + , (".pyw", LSPServerConfig "pyright-langserver" ["--stdio"] "python") + , (".go", LSPServerConfig "gopls" [] "go") + , (".rs", LSPServerConfig "rust-analyzer" [] "rust") + , (".c", LSPServerConfig "clangd" [] "c") + , (".cpp", LSPServerConfig "clangd" [] "cpp") + , (".h", LSPServerConfig "clangd" [] "c") + , (".hpp", LSPServerConfig "clangd" [] "cpp") + , (".java", LSPServerConfig "jdtls" [] "java") + , (".cs", LSPServerConfig "omnisharp" [] "csharp") + , (".rb", LSPServerConfig "solargraph" ["--stdio"] "ruby") + , (".hs", LSPServerConfig "haskell-language-server" ["--lsp"] "haskell") + , (".lhs", LSPServerConfig "haskell-language-server" ["--lsp"] "haskell") + , (".php", LSPServerConfig "phpactor" [] "php") + , (".swift", LSPServerConfig "sourcekit-lsp" [] "swift") + , (".kt", LSPServerConfig "kotlin-language-server" [] "kotlin") + , (".kts", LSPServerConfig "kotlin-language-server" [] "kotlin") + , (".scala", LSPServerConfig "metals" [] "scala") + , (".lua", LSPServerConfig "lua-language-server" [] "lua") + , (".zig", LSPServerConfig "zls" [] "zig") + , (".ex", LSPServerConfig "elixir-ls" [] "elixir") + , (".exs", LSPServerConfig "elixir-ls" [] "elixir") + , (".dart", LSPServerConfig "dart" ["analyze", "--stdio"] "dart") + , (".vue", LSPServerConfig "vue-language-server" [] "vue") + , (".svelte",LSPServerConfig "svelte-language-server" [] "svelte") + -- NEW: Nix and JSON LSP servers + , (".nix", LSPServerConfig "nixd" [] "nix") + , (".json", LSPServerConfig "vscode-json-language-server" ["--stdio"] "json") + ] + +-- | Default language ID mapping for extensions. +-- This replaces the hardcoded `languageIdFromExt` function. +defaultLanguageIds :: Map String Text +defaultLanguageIds = Map.fromList + [ (".py", "python") + , (".pyw", "python") + , (".hs", "haskell") + , (".lhs", "haskell") + , (".js", "javascript") + , (".jsx", "javascriptreact") + , (".ts", "typescript") + , (".tsx", "typescriptreact") + , (".go", "go") + , (".rs", "rust") + , (".c", "c") + , (".cpp", "cpp") + , (".h", "c") + , (".hpp", "cpp") + , (".java", "java") + , (".cs", "csharp") + , (".rb", "ruby") + , (".php", "php") + , (".swift", "swift") + , (".kt", "kotlin") + , (".kts", "kotlin") + , (".scala", "scala") + , (".lua", "lua") + , (".zig", "zig") + , (".ex", "elixir") + , (".exs", "elixir") + , (".dart", "dart") + , (".vue", "vue") + , (".svelte","svelte") + -- NEW: Nix, JSON, text, RAML + , (".nix", "nix") + , (".json", "json") + , (".text", "plaintext") + , (".raml", "raml") + ] + +-- ─────────────────────────────────────────────── +-- File Extension Configuration +-- ─────────────────────────────────────────────── + +-- | File extension categories for detection. +-- Mirrors FileCategory from Domain.Types but as simple strings for config. +data FileExtensionConfig = FileExtensionConfig + { fecCode :: [String] + , fecDoc :: [String] + , fecPaper :: [String] + , fecImage :: [String] + , fecVideo :: [String] + } deriving (Eq, Show, Generic) + +instance ToJSON FileExtensionConfig where + toJSON = genericToJSON defaultOptions { fieldLabelModifier = drop 3 } + +instance FromJSON FileExtensionConfig where + parseJSON = genericParseJSON defaultOptions { fieldLabelModifier = drop 3 } + +-- | Default file extension categories (hardcoded fallback). +defaultFileExtensions :: FileExtensionConfig +defaultFileExtensions = FileExtensionConfig + { fecCode = [ ".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".java", ".c", ".cpp", ".h", ".hpp" + , ".rb", ".cs", ".kt", ".kts", ".scala", ".php", ".swift", ".lua", ".zig", ".hs", ".lhs" + , ".ex", ".exs", ".m", ".mm", ".jl", ".vue", ".svelte", ".dart", ".ps1" + , ".nix", ".json" -- NEW + ] + , fecDoc = [ ".md", ".txt", ".rst", ".adoc", ".org" + , ".text", ".raml" -- NEW + ] + , fecPaper = [ ".pdf" ] + , fecImage = [ ".png", ".jpg", ".jpeg", ".webp", ".gif" ] + , fecVideo = [ ".mp4", ".mov", ".mkv", ".webm", ".avi", ".m4v", ".mp3", ".wav", ".m4a", ".ogg" ] + } + +-- ─────────────────────────────────────────────── +-- Top-level Configuration +-- ─────────────────────────────────────────────── + +-- | Top-level Graphos configuration. +-- Loaded from graphos.yaml, with defaults for missing fields. +data GraphosConfig = GraphosConfig + { gcLsp :: Map String LSPServerConfig -- ^ extension → LSP server config + , gcLanguageIds :: Map String Text -- ^ extension → language ID + , gcFileExtensions :: FileExtensionConfig -- ^ file extension categories + } deriving (Eq, Show, Generic) + +-- | Default Graphos configuration (used when no config file is found). +defaultGraphosConfig :: GraphosConfig +defaultGraphosConfig = GraphosConfig + { gcLsp = defaultLSPServers + , gcLanguageIds = defaultLanguageIds + , gcFileExtensions = defaultFileExtensions + } \ No newline at end of file diff --git a/src/Graphos/Domain/Types.hs b/src/Graphos/Domain/Types.hs index a2c2c6e..77f27bb 100644 --- a/src/Graphos/Domain/Types.hs +++ b/src/Graphos/Domain/Types.hs @@ -54,6 +54,7 @@ import Data.Map.Strict (Map) import Data.Text (Text) import qualified Data.Text as T import GHC.Generics (Generic) +import Graphos.Domain.Config (GraphosConfig, defaultGraphosConfig) -- | Unique identifier for a node (derived from file + entity name) type NodeId = Text @@ -453,6 +454,7 @@ data PipelineConfig = PipelineConfig , cfgMinCommSize :: Int -- ^ minimum community size; smaller ones get merged (default: 3) , cfgThreads :: Int -- ^ number of parallel extraction threads (default: 1) , cfgCommunityGraph :: Bool -- ^ export community-level graph JSON for LLM navigation + , cfgGraphosConfig :: GraphosConfig -- ^ LSP servers, language IDs, file extensions (config-driven) } deriving (Eq, Show) -- | Edge density level for inference @@ -489,4 +491,5 @@ defaultConfig = PipelineConfig , cfgMinCommSize = 3 , cfgThreads = 1 , cfgCommunityGraph = False + , cfgGraphosConfig = defaultGraphosConfig } \ No newline at end of file diff --git a/src/Graphos/Infrastructure/Config.hs b/src/Graphos/Infrastructure/Config.hs new file mode 100644 index 0000000..eca66de --- /dev/null +++ b/src/Graphos/Infrastructure/Config.hs @@ -0,0 +1,117 @@ +-- | Configuration loader - reads graphos.yaml and merges with defaults. +-- This is the only module that performs IO for config loading. +-- All config types live in Domain.Config (pure). +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +module Graphos.Infrastructure.Config + ( -- * Loading + loadConfig + , loadConfigFrom + + -- * Resolution helpers + , findLSPServerFromConfig + , languageIdFromConfig + + -- * Re-export domain types + , module Graphos.Domain.Config + ) where + +import Control.Exception (catch, SomeException(..)) +import qualified Data.ByteString as BS +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as Map +import Data.Text (Text) +import Data.Yaml (FromJSON(..), withObject, (.:?)) +import System.Directory (doesFileExist) +import qualified Data.Yaml as Yaml + +import Graphos.Domain.Config + +-- ─────────────────────────────────────────────── +-- Configuration file format (YAML) +-- ─────────────────────────────────────────────── + +-- | Intermediate type for parsing the YAML file. +-- User provides only overrides; defaults are merged. +data ConfigFile = ConfigFile + { cfLsp :: Maybe (Map String LSPServerConfig) + , cfLanguageIds :: Maybe (Map String Text) + , cfFileExtensions :: Maybe FileExtensionConfig + } deriving (Eq, Show) + +instance FromJSON ConfigFile where + parseJSON = withObject "ConfigFile" $ \v -> ConfigFile + <$> v .:? "lsp" + <*> v .:? "language_ids" + <*> v .:? "file_extensions" + +-- ─────────────────────────────────────────────── +-- Loading +-- ─────────────────────────────────────────────── + +-- | Load Graphos configuration from the default path (./graphos.yaml). +-- Falls back to defaults if the file doesn't exist or has parse errors. +loadConfig :: IO GraphosConfig +loadConfig = loadConfigFrom "graphos.yaml" + +-- | Load Graphos configuration from a specific file path. +-- Falls back to defaults if the file doesn't exist or has parse errors. +loadConfigFrom :: FilePath -> IO GraphosConfig +loadConfigFrom path = do + exists <- doesFileExist path + if not exists + then do + putStrLn $ "[config] No config file at " ++ path ++ " — using defaults" + pure defaultGraphosConfig + else do + result <- catch + (do content <- BS.readFile path + case Yaml.decodeEither' content of + Right (cfg :: ConfigFile) -> pure $ Right cfg + Left err -> pure $ Left $ "YAML parse error: " ++ show err + ) + $ \(e :: SomeException) -> pure $ Left $ "Config read error: " ++ show e + case result of + Left err -> do + putStrLn $ "[config] " ++ err ++ " — using defaults" + pure defaultGraphosConfig + Right cfgFile -> do + putStrLn $ "[config] Loaded " ++ path + pure $ mergeConfig cfgFile defaultGraphosConfig + +-- | Merge user config overrides onto defaults. +-- User values take precedence; missing fields fall back to defaults. +mergeConfig :: ConfigFile -> GraphosConfig -> GraphosConfig +mergeConfig cfgFile defaults = GraphosConfig + { gcLsp = case cfLsp cfgFile of + Just userLsp -> Map.union userLsp (gcLsp defaults) + Nothing -> gcLsp defaults + , gcLanguageIds = case cfLanguageIds cfgFile of + Just userIds -> Map.union userIds (gcLanguageIds defaults) + Nothing -> gcLanguageIds defaults + , gcFileExtensions = case cfFileExtensions cfgFile of + Just userExts -> userExts -- full override for file extensions + Nothing -> gcFileExtensions defaults + } + +-- ─────────────────────────────────────────────── +-- Resolution helpers (replace hardcoded lookups) +-- ─────────────────────────────────────────────── + +-- | Find an LSP server for a file extension from the config. +-- Returns the command and args if found, Nothing otherwise. +findLSPServerFromConfig :: GraphosConfig -> String -> IO (Maybe (String, [String])) +findLSPServerFromConfig config ext = + case Map.lookup ext (gcLsp config) of + Just server -> do + let cmd = lspCommand server + if null cmd + then pure Nothing -- empty command = explicitly disabled + else pure $ Just (cmd, lspArgs server) + Nothing -> pure Nothing + +-- | Look up a language ID for a file extension from the config. +-- Falls back to "plaintext" for unknown extensions. +languageIdFromConfig :: GraphosConfig -> String -> Text +languageIdFromConfig config ext = + Map.findWithDefault "plaintext" ext (gcLanguageIds config) \ No newline at end of file diff --git a/src/Graphos/Infrastructure/LSP/Client.hs b/src/Graphos/Infrastructure/LSP/Client.hs index 86ade50..84fc5d7 100644 --- a/src/Graphos/Infrastructure/LSP/Client.hs +++ b/src/Graphos/Infrastructure/LSP/Client.hs @@ -82,9 +82,14 @@ data LSPClient = LSPClient } -- ─────────────────────────────────────────────── --- Language server registry +-- Language server registry (default / hardcoded) +-- Prefer using findLSPServerFromConfig with a GraphosConfig +-- for user-configurable LSP servers. This fallback remains +-- for cases where no config is available (e.g. tests). -- ─────────────────────────────────────────────── +-- | Default hardcoded LSP server commands. +-- Kept for backward compatibility; prefer 'defaultLSPServers' from Domain.Config. languageServerCommands :: Map String (String, [String]) languageServerCommands = Map.fromList [ (".ts", ("typescript-language-server", ["--stdio"])) @@ -115,8 +120,12 @@ languageServerCommands = Map.fromList , (".dart", ("dart", ["analyze", "--stdio"])) , (".vue", ("vue-language-server", [])) , (".svelte", ("svelte-language-server", [])) + , (".nix", ("nixd", [])) + , (".json", ("vscode-json-language-server", ["--stdio"])) ] +-- | Find an LSP server for a file extension using the default hardcoded registry. +-- Prefer 'findLSPServerFromConfig' from Infrastructure.Config for user-configurable lookups. findLSPServer :: String -> IO (Maybe (String, [String])) findLSPServer ext = do case Map.lookup ext languageServerCommands of diff --git a/src/Graphos/Infrastructure/LSP/Protocol.hs b/src/Graphos/Infrastructure/LSP/Protocol.hs index 3cbeafa..c02f4d8 100644 --- a/src/Graphos/Infrastructure/LSP/Protocol.hs +++ b/src/Graphos/Infrastructure/LSP/Protocol.hs @@ -292,36 +292,41 @@ lspDidClose filePath = JSONRPCNotification ] } --- | Map file extension to LSP language ID +-- | Map file extension to LSP language ID (default / hardcoded). +-- Prefer 'languageIdFromExtConfig' with a GraphosConfig for user-configurable mappings. languageIdFromExt :: String -> Text languageIdFromExt ext = case ext of - ".py" -> "python" - ".pyw" -> "python" - ".hs" -> "haskell" - ".lhs" -> "haskell" - ".js" -> "javascript" - ".jsx" -> "javascriptreact" - ".ts" -> "typescript" - ".tsx" -> "typescriptreact" - ".go" -> "go" - ".rs" -> "rust" - ".c" -> "c" - ".cpp" -> "cpp" - ".h" -> "c" - ".hpp" -> "cpp" - ".java" -> "java" - ".cs" -> "csharp" - ".rb" -> "ruby" - ".php" -> "php" + ".py" -> "python" + ".pyw" -> "python" + ".hs" -> "haskell" + ".lhs" -> "haskell" + ".js" -> "javascript" + ".jsx" -> "javascriptreact" + ".ts" -> "typescript" + ".tsx" -> "typescriptreact" + ".go" -> "go" + ".rs" -> "rust" + ".c" -> "c" + ".cpp" -> "cpp" + ".h" -> "c" + ".hpp" -> "cpp" + ".java" -> "java" + ".cs" -> "csharp" + ".rb" -> "ruby" + ".php" -> "php" ".swift" -> "swift" - ".kt" -> "kotlin" - ".kts" -> "kotlin" + ".kt" -> "kotlin" + ".kts" -> "kotlin" ".scala" -> "scala" - ".lua" -> "lua" - ".zig" -> "zig" - ".ex" -> "elixir" - ".exs" -> "elixir" - ".dart" -> "dart" - ".vue" -> "vue" + ".lua" -> "lua" + ".zig" -> "zig" + ".ex" -> "elixir" + ".exs" -> "elixir" + ".dart" -> "dart" + ".vue" -> "vue" ".svelte" -> "svelte" - _ -> "plaintext" \ No newline at end of file + ".nix" -> "nix" + ".json" -> "json" + ".text" -> "plaintext" + ".raml" -> "raml" + _ -> "plaintext" \ No newline at end of file diff --git a/src/Graphos/UseCase/Detect.hs b/src/Graphos/UseCase/Detect.hs index 35a0b32..b92cf69 100644 --- a/src/Graphos/UseCase/Detect.hs +++ b/src/Graphos/UseCase/Detect.hs @@ -1,6 +1,7 @@ -- | File detection - scan directory for supported files module Graphos.UseCase.Detect ( detectFiles + , detectFilesWithExtensions , allSupportedExtensions ) where @@ -13,6 +14,8 @@ import System.FilePath (takeExtension, ()) import Graphos.Domain.Types -- | All supported file extensions organized by category +-- Prefer using config-driven extensions from GraphosConfig when available. +-- This hardcoded default serves as fallback. allSupportedExtensions :: Map FileCategory [String] allSupportedExtensions = Map.fromList [ (CodeFiles, codeExts) @@ -24,8 +27,10 @@ allSupportedExtensions = Map.fromList where codeExts = [".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".java", ".c", ".cpp", ".h", ".hpp" ,".rb", ".cs", ".kt", ".kts", ".scala", ".php", ".swift", ".lua", ".zig", ".hs", ".lhs" - ,".ex", ".exs", ".m", ".mm", ".jl", ".vue", ".svelte", ".dart", ".ps1"] - docExts = [".md", ".txt", ".rst", ".adoc", ".org"] + ,".ex", ".exs", ".m", ".mm", ".jl", ".vue", ".svelte", ".dart", ".ps1" + ,".nix", ".json"] -- NEW: Nix and JSON + docExts = [".md", ".txt", ".rst", ".adoc", ".org" + ,".text", ".raml"] -- NEW: plain text and RAML paperExts = [".pdf"] imageExts = [".png", ".jpg", ".jpeg", ".webp", ".gif"] videoExts = [".mp4", ".mov", ".mkv", ".webm", ".avi", ".m4v", ".mp3", ".wav", ".m4a", ".ogg"] @@ -56,26 +61,65 @@ detectFiles root = do , detectionFiles = categorized } --- | Find all files recursively +-- | Detect files in a directory using config-driven extension categories. +detectFilesWithExtensions :: FilePath -> Map FileCategory [String] -> IO Detection +detectFilesWithExtensions root extMap = do + exists <- doesDirectoryExist root + if not exists + then pure Detection + { detectionTotalFiles = 0 + , detectionTotalWords = 0 + , detectionNeedsGraph = False + , detectionWarning = Just $ T.pack $ "Directory not found: " ++ root + , detectionFiles = Map.empty + } + else do + files <- findAllFilesWith root extMap + let categorized = categorizeFilesWith files extMap + totalFiles = sum (length <$> Map.elems categorized) + pure Detection + { detectionTotalFiles = totalFiles + , detectionTotalWords = 0 + , detectionNeedsGraph = totalFiles > 0 + , detectionWarning = if totalFiles > 200 + then Just $ T.pack $ "Large corpus: " ++ show totalFiles ++ " files" + else Nothing + , detectionFiles = categorized + } + +-- | Find all files recursively (using default extensions) findAllFiles :: FilePath -> IO [FilePath] -findAllFiles dir = do +findAllFiles dir = findAllFilesWith dir allSupportedExtensions + +-- | Find all files recursively using config-driven extension map +findAllFilesWith :: FilePath -> Map FileCategory [String] -> IO [FilePath] +findAllFilesWith dir extMap = do entries <- listDirectory dir fmap concat $ mapM (\entry -> do let path = dir entry isDir <- doesDirectoryExist path if isDir && not (isIgnored entry) - then findAllFiles path - else if isSupported entry + then findAllFilesWith path extMap + else if isSupportedWith entry extMap then pure [path] else pure [] ) entries - where - isIgnored entry = entry `elem` [".git", "node_modules", "__pycache__", ".venv", "dist", "dist-newstyle", "build", ".stack-work", "graphos-out", ".opencode", ".tmp", ".obsidian", ".github"] - isSupported f = takeExtension f `elem` concat (Map.elems allSupportedExtensions) --- | Categorize files by type +-- | Categorize files by type (using default extensions) categorizeFiles :: [FilePath] -> Map FileCategory [FilePath] -categorizeFiles files = Map.fromList +categorizeFiles files = categorizeFilesWith files allSupportedExtensions + +-- | Categorize files by type using config-driven extension map +categorizeFilesWith :: [FilePath] -> Map FileCategory [String] -> Map FileCategory [FilePath] +categorizeFilesWith files extMap = Map.fromList [ (cat, filter (\f -> takeExtension f `elem` exts) files) - | (cat, exts) <- Map.toList allSupportedExtensions - ] \ No newline at end of file + | (cat, exts) <- Map.toList extMap + ] + +-- | Check if a directory entry should be ignored +isIgnored :: String -> Bool +isIgnored entry = entry `elem` [".git", "node_modules", "__pycache__", ".venv", "dist", "dist-newstyle", "build", ".stack-work", "graphos-out", ".opencode", ".tmp", ".obsidian", ".github"] + +-- | Check if a file has a supported extension (using config-driven extensions) +isSupportedWith :: String -> Map FileCategory [String] -> Bool +isSupportedWith f extMap = takeExtension f `elem` concat (Map.elems extMap) \ No newline at end of file From 04c1311ac685d2e15f5861508404d3e5eccd39a6 Mon Sep 17 00:00:00 2001 From: Jeremie Date: Mon, 20 Apr 2026 20:23:52 +0200 Subject: [PATCH 2/3] Optimize CI: parallel jobs, freeze-based cache, -j4, concurrency control - Split build+test and haddock into parallel jobs - Haddock only runs on main push (skipped on PRs) - Cache keyed on cabal.project.freeze for better hit rates - -j4 parallel compilation throughout - Concurrency group cancels redundant in-progress runs - Remove --enable-benchmarks (unused) - DRY env vars for GHC/Cabal versions --- .github/workflows/haskell.yml | 67 ++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 13 deletions(-) diff --git a/.github/workflows/haskell.yml b/.github/workflows/haskell.yml index 53db7c9..385d8de 100644 --- a/.github/workflows/haskell.yml +++ b/.github/workflows/haskell.yml @@ -6,47 +6,88 @@ on: pull_request: branches: ["main"] +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + GHC_VERSION: "9.10" + CABAL_VERSION: "3.14" + permissions: contents: read jobs: - build: + build-and-test: runs-on: ubuntu-latest - steps: - uses: actions/checkout@v4 - uses: haskell-actions/setup@v2 id: setup-haskell with: - ghc-version: "9.10" - cabal-version: "3.14" + ghc-version: ${{ env.GHC_VERSION }} + cabal-version: ${{ env.CABAL_VERSION }} - name: Configure - run: cabal configure --enable-tests --enable-benchmarks --flag dev + run: cabal configure --enable-tests --flag dev -j4 + + - name: Freeze dependencies + run: cabal freeze - name: Cache uses: actions/cache@v4 - env: - cache-name: cache-cabal with: path: | ${{ steps.setup-haskell.outputs.cabal-store }} dist-newstyle - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/*.cabal') }}-${{ hashFiles('**/cabal.project') }} + key: ${{ runner.os }}-ghc${{ env.GHC_VERSION }}-cabal-${{ hashFiles('cabal.project.freeze') }} restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}- - ${{ runner.os }}-build- - ${{ runner.os }}- + ${{ runner.os }}-ghc${{ env.GHC_VERSION }}-cabal- - name: Install dependencies - run: cabal build --only-dependencies all + run: cabal build --only-dependencies all -j4 - name: Build - run: cabal build all + run: cabal build all -j4 - name: Run tests run: cabal test all + haddock: + runs-on: ubuntu-latest + if: github.event_name == 'push' + needs: build-and-test + steps: + - uses: actions/checkout@v4 + + - uses: haskell-actions/setup@v2 + id: setup-haskell + with: + ghc-version: ${{ env.GHC_VERSION }} + cabal-version: ${{ env.CABAL_VERSION }} + + - name: Configure + run: cabal configure --enable-tests --flag dev -j4 + + - name: Freeze dependencies + run: cabal freeze + + - name: Cache + uses: actions/cache@v4 + with: + path: | + ${{ steps.setup-haskell.outputs.cabal-store }} + dist-newstyle + key: ${{ runner.os }}-ghc${{ env.GHC_VERSION }}-cabal-${{ hashFiles('cabal.project.freeze') }} + restore-keys: | + ${{ runner.os }}-ghc${{ env.GHC_VERSION }}-cabal- + + - name: Build dependencies + run: cabal build --only-dependencies all -j4 + + - name: Build + run: cabal build all -j4 + - name: Haddock run: cabal haddock all From a002fdb8cb12215366c590df793268c65c0e57bc Mon Sep 17 00:00:00 2001 From: Jeremie Date: Mon, 20 Apr 2026 21:20:30 +0200 Subject: [PATCH 3/3] ci: install hspec-discover before build to fix pre-processor error --- .github/workflows/haskell.yml | 120 ++++++++++++++++++---------------- 1 file changed, 63 insertions(+), 57 deletions(-) diff --git a/.github/workflows/haskell.yml b/.github/workflows/haskell.yml index 385d8de..3b7fe0e 100644 --- a/.github/workflows/haskell.yml +++ b/.github/workflows/haskell.yml @@ -21,73 +21,79 @@ jobs: build-and-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - uses: haskell-actions/setup@v2 - id: setup-haskell - with: - ghc-version: ${{ env.GHC_VERSION }} - cabal-version: ${{ env.CABAL_VERSION }} + - uses: haskell-actions/setup@v2 + id: setup-haskell + with: + ghc-version: ${{ env.GHC_VERSION }} + cabal-version: ${{ env.CABAL_VERSION }} - - name: Configure - run: cabal configure --enable-tests --flag dev -j4 + - name: Configure + run: cabal configure --enable-tests --flag dev -j4 - - name: Freeze dependencies - run: cabal freeze + - name: Freeze dependencies + run: cabal freeze - - name: Cache - uses: actions/cache@v4 - with: - path: | - ${{ steps.setup-haskell.outputs.cabal-store }} - dist-newstyle - key: ${{ runner.os }}-ghc${{ env.GHC_VERSION }}-cabal-${{ hashFiles('cabal.project.freeze') }} - restore-keys: | - ${{ runner.os }}-ghc${{ env.GHC_VERSION }}-cabal- + - name: Cache + uses: actions/cache@v4 + with: + path: | + ${{ steps.setup-haskell.outputs.cabal-store }} + dist-newstyle + key: ${{ runner.os }}-ghc${{ env.GHC_VERSION }}-cabal-${{ hashFiles('cabal.project.freeze') }} + restore-keys: | + ${{ runner.os }}-ghc${{ env.GHC_VERSION }}-cabal- - - name: Install dependencies - run: cabal build --only-dependencies all -j4 + - name: Install dependencies + run: cabal build --only-dependencies all -j4 - - name: Build - run: cabal build all -j4 + - name: Install test tooling + run: cabal install hspec-discover --install-method=copy -j4 - - name: Run tests - run: cabal test all + - name: Build + run: cabal build all -j4 + + - name: Run tests + run: cabal test all haddock: runs-on: ubuntu-latest if: github.event_name == 'push' needs: build-and-test steps: - - uses: actions/checkout@v4 - - - uses: haskell-actions/setup@v2 - id: setup-haskell - with: - ghc-version: ${{ env.GHC_VERSION }} - cabal-version: ${{ env.CABAL_VERSION }} - - - name: Configure - run: cabal configure --enable-tests --flag dev -j4 - - - name: Freeze dependencies - run: cabal freeze - - - name: Cache - uses: actions/cache@v4 - with: - path: | - ${{ steps.setup-haskell.outputs.cabal-store }} - dist-newstyle - key: ${{ runner.os }}-ghc${{ env.GHC_VERSION }}-cabal-${{ hashFiles('cabal.project.freeze') }} - restore-keys: | - ${{ runner.os }}-ghc${{ env.GHC_VERSION }}-cabal- - - - name: Build dependencies - run: cabal build --only-dependencies all -j4 - - - name: Build - run: cabal build all -j4 - - - name: Haddock - run: cabal haddock all + - uses: actions/checkout@v4 + + - uses: haskell-actions/setup@v2 + id: setup-haskell + with: + ghc-version: ${{ env.GHC_VERSION }} + cabal-version: ${{ env.CABAL_VERSION }} + + - name: Configure + run: cabal configure --enable-tests --flag dev -j4 + + - name: Freeze dependencies + run: cabal freeze + + - name: Cache + uses: actions/cache@v4 + with: + path: | + ${{ steps.setup-haskell.outputs.cabal-store }} + dist-newstyle + key: ${{ runner.os }}-ghc${{ env.GHC_VERSION }}-cabal-${{ hashFiles('cabal.project.freeze') }} + restore-keys: | + ${{ runner.os }}-ghc${{ env.GHC_VERSION }}-cabal- + + - name: Build dependencies + run: cabal build --only-dependencies all -j4 + + - name: Install test tooling + run: cabal install hspec-discover --install-method=copy -j4 + + - name: Build + run: cabal build all -j4 + + - name: Haddock + run: cabal haddock all