oo-ide 0.0.4

∞ is a terminal IDE focused on low distraction, high usability.
Documentation
//! Schema provider system for structured file validation and completion.
//!
//! # Overview
//!
//! A [`SchemaProvider`] maps file paths to [`SchemaContent`] (JSON Schema
//! strings).  Multiple providers live in a [`SchemaRegistry`]; the first
//! matching provider wins when resolving a file.
//!
//! Extensions may declare packaged schemas in `extension.yaml` and include schema files inside the `.cyix` archive; the manager registers those schemas when the extension is enabled.  Built-in providers can be registered directly via [`SchemaRegistry::register`].
//!
//! # File matching
//!
//! Each provider describes the files it covers via [`FilePattern`]:
//!
//! | Pattern                       | Kind     | Matches                            |
//! |-------------------------------|----------|------------------------------------|
//! | `"Cargo.toml"`                | Filename | Any file named `Cargo.toml`        |
//! | `"*.yaml"`                    | Glob     | Any YAML file (by name)            |
//! | `"**/*.json"`                 | Glob     | Any JSON file anywhere             |
//! | `".github/workflows/*.yml"`   | Glob     | GitHub Actions workflow files      |

pub mod registry;
pub mod deep_completion;
pub mod context;
pub mod format;
pub use deep_completion::{completions_from_schema, completions_from_parsed_schema};

pub use registry::SchemaRegistry;

use std::path::Path;

// ---------------------------------------------------------------------------
// Core types
// ---------------------------------------------------------------------------

/// Content of a resolved schema.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SchemaContent {
    /// Stable identifier (e.g. `"cargo-toml"`, `"github-actions"`).
    pub id: String,
    /// Human-readable name shown in the UI.
    pub name: String,
    /// The schema encoded as a JSON string (JSON Schema draft-07 or later).
    pub schema_json: String,
}

/// A file-matching pattern used by schema providers to declare which files
/// they cover.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FilePattern {
    /// Matches files whose **filename** (not full path) equals this string
    /// exactly.
    ///
    /// Example: `FilePattern::Filename("Cargo.toml".into())` matches
    /// `/project/Cargo.toml` and `./Cargo.toml`.
    Filename(String),

    /// Matches files using a glob pattern.
    ///
    /// Supports:
    /// - `*` — any characters within a single path segment (no `/`).
    /// - `**` — zero or more path segments including separators.
    ///
    /// Matching is tried against the full path **and** each path suffix, so
    /// `"*.yaml"` matches `/project/config.yaml` and `".github/workflows/*.yml"`
    /// matches `/project/.github/workflows/ci.yml`.
    Glob(String),
}

impl FilePattern {
    /// Returns `true` if `path` matches this pattern.
    pub fn matches(&self, path: &Path) -> bool {
        match self {
            FilePattern::Filename(name) => path
                .file_name()
                .and_then(|n| n.to_str())
                .is_some_and(|n| n == name),

            FilePattern::Glob(glob) => {
                // Normalise path separators so the matcher is consistent on
                // all platforms.
                let path_str = path.to_string_lossy();
                let normalised = path_str.replace('\\', "/");
                let pat = glob.as_bytes();

                // Try the full normalised path first.
                if glob_match(pat, normalised.as_bytes()) {
                    return true;
                }
                // Then try each suffix after a `/` separator, so a pattern
                // like `"*.yaml"` matches `/project/config.yaml` (suffix
                // `"config.yaml"`) and `".github/workflows/*.yml"` matches
                // `/project/.github/workflows/ci.yml` (suffix
                // `".github/workflows/ci.yml"`).
                for (i, _) in normalised.match_indices('/') {
                    if glob_match(pat, &normalised.as_bytes()[i + 1..]) {
                        return true;
                    }
                }
                false
            }
        }
    }
}

// ---------------------------------------------------------------------------
// SchemaProvider trait
// ---------------------------------------------------------------------------

/// A type that can provide a JSON Schema for a given file path.
///
/// Implement this trait for built-in providers and register them via
/// [`SchemaRegistry::register`].  Extensions use the `RegisterSchema`
/// host-request to register schemas dynamically at runtime.
pub trait SchemaProvider: Send + Sync {
    /// Stable identifier for this provider (e.g. `"cargo-toml"`).
    fn id(&self) -> &str;

    /// Human-readable name (e.g. `"Cargo.toml schema"`).
    fn name(&self) -> &str;

    /// Returns the [`SchemaContent`] for `path` if this provider covers it,
    /// or `None` otherwise.
    fn schema_for(&self, path: &Path) -> Option<SchemaContent>;
}

// ---------------------------------------------------------------------------
// Minimal glob matcher
// ---------------------------------------------------------------------------

/// Recursively matches `pat` against `text` using shell-style globs.
///
/// Rules:
/// - `**` matches zero or more path segments (including `/`).
/// - `*` matches zero or more characters within a single segment (no `/`).
/// - All other bytes must match literally.
fn glob_match(pat: &[u8], text: &[u8]) -> bool {
    match pat {
        // Empty pattern matches only empty text.
        [] => text.is_empty(),

        // `**` — consume zero or more complete path segments.
        [b'*', b'*', rest @ ..] => {
            // Strip an optional leading separator from the rest-of-pattern.
            let rest = rest.strip_prefix(b"/").unwrap_or(rest);
            // `**` with no remaining pattern matches any text unconditionally.
            if rest.is_empty() {
                return true;
            }
            // Try matching `rest` from the current position (zero segments),
            // then again after every `/` in `text` (one or more segments).
            if glob_match(rest, text) {
                return true;
            }
            for i in 0..text.len() {
                if text[i] == b'/' && glob_match(rest, &text[i + 1..]) {
                    return true;
                }
            }
            false
        }

        // `*` — match any run of non-separator characters.
        [b'*', rest @ ..] => {
            for i in 0..=text.len() {
                // Stop before a path separator: `*` does not cross `/`.
                if i < text.len() && text[i] == b'/' {
                    break;
                }
                if glob_match(rest, &text[i..]) {
                    return true;
                }
            }
            false
        }

        // Literal byte — must match the head of `text`.
        [p, rest_p @ ..] => match text {
            [t, rest_t @ ..] if p == t => glob_match(rest_p, rest_t),
            _ => false,
        },
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    fn p(s: &str) -> PathBuf {
        PathBuf::from(s)
    }

    // --- FilePattern::Filename ---

    #[test]
    fn filename_matches_exact_name() {
        let pat = FilePattern::Filename("Cargo.toml".into());
        assert!(pat.matches(&p("Cargo.toml")));
        assert!(pat.matches(&p("/project/Cargo.toml")));
        assert!(pat.matches(&p("nested/dir/Cargo.toml")));
    }

    #[test]
    fn filename_case_sensitive() {
        let pat = FilePattern::Filename("Cargo.toml".into());
        assert!(!pat.matches(&p("cargo.toml")));
        assert!(!pat.matches(&p("CARGO.TOML")));
    }

    #[test]
    fn filename_no_match_different_file() {
        let pat = FilePattern::Filename("Cargo.toml".into());
        assert!(!pat.matches(&p("/project/package.json")));
    }

    // --- FilePattern::Glob ---

    #[test]
    fn glob_star_matches_extension() {
        let pat = FilePattern::Glob("*.yaml".into());
        assert!(pat.matches(&p("config.yaml")));
        assert!(pat.matches(&p("/project/config.yaml")));
        assert!(pat.matches(&p("nested/deep/config.yaml")));
    }

    #[test]
    fn glob_star_does_not_match_different_extension() {
        let pat = FilePattern::Glob("*.yaml".into());
        assert!(!pat.matches(&p("config.json")));
        assert!(!pat.matches(&p("config.yaml.bak")));
    }

    #[test]
    fn glob_double_star_matches_any_depth() {
        let pat = FilePattern::Glob("**/*.json".into());
        assert!(pat.matches(&p("top.json")));
        assert!(pat.matches(&p("/project/a/b/c.json")));
        assert!(!pat.matches(&p("/project/a/b/c.yaml")));
    }

    #[test]
    fn glob_path_pattern_workflows() {
        let pat = FilePattern::Glob(".github/workflows/*.yml".into());
        assert!(pat.matches(&p(".github/workflows/ci.yml")));
        assert!(pat.matches(&p("/project/.github/workflows/release.yml")));
        // `*` must not cross a separator inside the segment.
        assert!(!pat.matches(&p(".github/workflows/sub/ci.yml")));
    }

    #[test]
    fn glob_literal_filename() {
        let pat = FilePattern::Glob("pyproject.toml".into());
        assert!(pat.matches(&p("pyproject.toml")));
        assert!(pat.matches(&p("/project/pyproject.toml")));
        assert!(!pat.matches(&p("/project/other.toml")));
    }

    // --- glob_match (unit tests for the internal function) ---

    #[test]
    fn glob_match_empty_matches_empty() {
        assert!(glob_match(b"", b""));
        assert!(!glob_match(b"", b"x"));
    }

    #[test]
    fn glob_match_literal() {
        assert!(glob_match(b"hello", b"hello"));
        assert!(!glob_match(b"hello", b"world"));
        assert!(!glob_match(b"hello", b"hell"));
    }

    #[test]
    fn glob_match_star_within_segment() {
        assert!(glob_match(b"*.rs", b"main.rs"));
        assert!(glob_match(b"*.rs", b".rs")); // zero chars before ext
        // `*` must not cross path separators.
        assert!(!glob_match(b"*.rs", b"src/main.rs"));
    }

    #[test]
    fn glob_match_double_star() {
        assert!(glob_match(b"**/*.rs", b"src/main.rs"));
        assert!(glob_match(b"**/*.rs", b"a/b/c/main.rs"));
        assert!(glob_match(b"**/*.rs", b"main.rs")); // zero segments
        assert!(!glob_match(b"**/*.rs", b"main.py"));
    }

    #[test]
    fn glob_match_double_star_only() {
        assert!(glob_match(b"**", b""));
        assert!(glob_match(b"**", b"a/b/c"));
        assert!(glob_match(b"**", b"anything"));
    }
}