diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3ad4e84..138dac6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,7 +27,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.10" - uses: pre-commit/action@v3.0.1 test-cargo: @@ -49,16 +49,15 @@ jobs: matrix: os: [ubuntu-latest] python-version: - - '3.9' - '3.10' - '3.11' - '3.12' - - 'pypy3.9' + - 'pypy3.10' include: - os: windows-latest - python-version: '3.9' + python-version: '3.10' - os: macos-latest - python-version: '3.9' + python-version: '3.10' runs-on: ${{ matrix.os }} diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 5e6d9fd..ff2442f 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -4,13 +4,14 @@ version: 2 sphinx: + configuration: docs/conf.py builder: html build: os: "ubuntu-22.04" tools: - python: "3.9" - rust: "1.75" + python: "3.10" + rust: "1.78" python: install: diff --git a/crates/analyzer/Cargo.toml b/crates/analyzer/Cargo.toml index 57fc287..7f13740 100644 --- a/crates/analyzer/Cargo.toml +++ b/crates/analyzer/Cargo.toml @@ -20,6 +20,7 @@ serde.workspace = true serde_json.workspace = true syn.workspace = true toml.workspace = true +cargo_metadata = "0.18" [dev-dependencies] insta.workspace = true diff --git a/crates/analyzer/src/analyze/crate_.rs b/crates/analyzer/src/analyze/crate_.rs index e72aac9..8367809 100644 --- a/crates/analyzer/src/analyze/crate_.rs +++ b/crates/analyzer/src/analyze/crate_.rs @@ -1,68 +1,65 @@ //! Analyze the crate -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; +use cargo_metadata::{MetadataCommand, Target}; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; use crate::data_model::{Crate, Enum, Function, Module, Struct}; pub fn analyze_crate(path: &str) -> Result { // make the path absolute // TODO we use dunce to canonicalize the path because otherwise there is issues with python's os.path.relpath on windows, but maybe we should fix this on the Python side - let path = - dunce::canonicalize(path).context(format!("Error resolving crate path: {}", path))?; + let crate_dir = + dunce::canonicalize(path).context(format!("Error resolving crate path: {path}"))?; + eprintln!("running new analyzer"); // check the path is a directory - if !path.is_dir() { - return Err(anyhow::anyhow!(format!( + if !crate_dir.is_dir() { + return Err(anyhow!( "Crate path is not a directory: {}", - path.to_string_lossy() - ))); + crate_dir.to_string_lossy() + )); } // check if Cargo.toml exists - let cargo_toml_path = path.join("Cargo.toml"); + let cargo_toml_path = crate_dir.join("Cargo.toml"); if !cargo_toml_path.exists() { - return Err(anyhow::anyhow!(format!( + return Err(anyhow!( "Cargo.toml does not exist in: {}", - path.to_string_lossy() - ))); + crate_dir.to_string_lossy() + )); } - // read the Cargo.toml and initialize the Crate struct - let contents = std::fs::read_to_string(&cargo_toml_path)?; - let cargo_toml: CargoToml = toml::from_str(&contents).context(format!( - "Error parsing: {}", - cargo_toml_path.to_string_lossy() - ))?; + // use `cargo_metadata` instead of implementing own TOML parser + let metadata = MetadataCommand::new() + .manifest_path(&cargo_toml_path) + .exec() + .context("Failed to run `cargo metadata`")?; - // check whether the crate is a library or binary - let (crate_name, to_root) = if let Some(lib) = cargo_toml.lib { - if cargo_toml.bin.is_some() { - return Err(anyhow::anyhow!(format!( - "Both lib and bin sections in: {}", - path.to_string_lossy() - ))); - } - ( - lib.name.unwrap_or(cargo_toml.package.name), - lib.path.unwrap_or("src/lib.rs".to_string()), - ) - } else if let Some(bin) = cargo_toml.bin { - ( - bin.name.unwrap_or(cargo_toml.package.name), - bin.path.unwrap_or("src/main.rs".to_string()), - ) - } else { - return Err(anyhow::anyhow!(format!( - "No lib or bin section in: {}", - path.to_string_lossy() - ))); - }; + let root_pkg = metadata + .root_package() + .ok_or_else(|| anyhow!("`cargo metadata` returned no root package"))?; + + // Prefer library target; fall back to the first binary target + let root_target: &Target = root_pkg + .targets + .iter() + .find(|t| t.kind.contains(&"lib".into())) + .or_else(|| { + root_pkg + .targets + .iter() + .find(|t| t.kind.contains(&"bin".into())) + }) + .ok_or_else(|| anyhow!("No lib or bin target defined in manifest"))?; + + let crate_name = root_target.name.clone(); + let root_module = PathBuf::from(&root_target.src_path); let mut result = AnalysisResult::new(Crate { - name: crate_name, - version: cargo_toml.package.version.clone(), + name: crate_name.clone(), + version: root_pkg.version.to_string(), // workspace-aware }); // check existence of the root module - let root_module = path.join(to_root); if !root_module.exists() { return Ok(result); } @@ -74,6 +71,7 @@ pub fn analyze_crate(path: &str) -> Result { "Error parsing module {}", root_module.to_string_lossy() ))?; + let mut modules_to_read = module .declarations .iter() @@ -91,7 +89,7 @@ pub fn analyze_crate(path: &str) -> Result { result.enums.extend(enums); result.functions.extend(functions); - // recursively find/read the public sub-modules + // recursively find/read the public sub‑modules let mut read_modules = vec![]; while let Some((parent_dir, module_name, parent)) = modules_to_read.pop() { let (module_path, submodule_dir) = @@ -126,12 +124,12 @@ pub fn analyze_crate(path: &str) -> Result { "Error parsing module {}", module_path.to_string_lossy() ))?; + modules_to_read.extend( module .declarations .iter() - .map(|s| (submodule_dir.clone(), s.to_string(), path.clone())) - .collect::>(), + .map(|s| (submodule_dir.clone(), s.to_string(), path.clone())), ); result.modules.push(module); result.structs.extend(structs); @@ -164,31 +162,6 @@ impl AnalysisResult { } } -#[derive(Debug, Deserialize)] -struct CargoToml { - package: Package, - bin: Option, - lib: Option, -} - -#[derive(Debug, Deserialize)] -struct Package { - name: String, - version: String, -} - -#[derive(Debug, Deserialize)] -struct Lib { - name: Option, - path: Option, -} - -#[derive(Debug, Deserialize)] -struct Bin { - name: Option, - path: Option, -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/py_binding/Cargo.toml b/crates/py_binding/Cargo.toml index a658141..d5dd3ce 100644 --- a/crates/py_binding/Cargo.toml +++ b/crates/py_binding/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sphinx_rust" -version = "0.0.2" +version = "0.0.2-dev1" publish = false edition = "2021" authors.workspace = true diff --git a/crates/py_binding/src/data_query.rs b/crates/py_binding/src/data_query.rs index 86c0245..bc5da6a 100644 --- a/crates/py_binding/src/data_query.rs +++ b/crates/py_binding/src/data_query.rs @@ -26,8 +26,7 @@ where Ok(crate_) => crate_, Err(err) => { return Err(PyIOError::new_err(format!( - "Could not deserialize {}: {}", - name, err + "Could not deserialize {name}: {err}" ))) } }; @@ -39,7 +38,7 @@ where pub fn load_crate(cache_path: &str, name: &str) -> PyResult> { let path = std::path::Path::new(cache_path) .join("crates") - .join(format!("{}.json", name)); + .join(format!("{name}.json")); if !path.exists() { return Ok(None); } @@ -53,7 +52,7 @@ pub fn load_crate(cache_path: &str, name: &str) -> PyResult> { pub fn load_module(cache_path: &str, full_name: &str) -> PyResult> { let path = std::path::Path::new(cache_path) .join("modules") - .join(format!("{}.json", full_name)); + .join(format!("{full_name}.json")); if !path.exists() { return Ok(None); } @@ -67,7 +66,7 @@ pub fn load_module(cache_path: &str, full_name: &str) -> PyResult pub fn load_struct(cache_path: &str, full_name: &str) -> PyResult> { let path = std::path::Path::new(cache_path) .join("structs") - .join(format!("{}.json", full_name)); + .join(format!("{full_name}.json")); if !path.exists() { return Ok(None); } @@ -81,7 +80,7 @@ pub fn load_struct(cache_path: &str, full_name: &str) -> PyResult pub fn load_enum(cache_path: &str, full_name: &str) -> PyResult> { let path = std::path::Path::new(cache_path) .join("enums") - .join(format!("{}.json", full_name)); + .join(format!("{full_name}.json")); if !path.exists() { return Ok(None); } @@ -95,7 +94,7 @@ pub fn load_enum(cache_path: &str, full_name: &str) -> PyResult> { pub fn load_function(cache_path: &str, full_name: &str) -> PyResult> { let path = std::path::Path::new(cache_path) .join("functions") - .join(format!("{}.json", full_name)); + .join(format!("{full_name}.json")); if !path.exists() { return Ok(None); } @@ -106,14 +105,8 @@ pub fn load_function(cache_path: &str, full_name: &str) -> PyResult) -> Option { - let name = match path.file_stem() { - Some(name) => name, - None => return None, - }; - let name = match name.to_str() { - Some(name) => name, - None => return None, - }; + let name = path.file_stem()?; + let name = name.to_str()?; let name_path = name.split("::").collect::>(); if name_path.len() != parent.len() + 1 { return None; diff --git a/crates/py_binding/src/lib.rs b/crates/py_binding/src/lib.rs index cca1d9b..1383d11 100644 --- a/crates/py_binding/src/lib.rs +++ b/crates/py_binding/src/lib.rs @@ -162,25 +162,20 @@ where Ok(value) => value, Err(err) => { return Err(PyIOError::new_err(format!( - "Could not serialize value: {}", - err + "Could not serialize value: {err}" ))) } }; if path.exists() { - match std::fs::read_to_string(path) { - Ok(old_value) => { - if value == old_value { - return Ok(()); - } + if let Ok(old_value) = std::fs::read_to_string(path) { + if value == old_value { + return Ok(()); } - Err(_) => {} }; } match std::fs::write(path, value) { Err(err) => Err(PyIOError::new_err(format!( - "Could not write value to file: {}", - err + "Could not write value to file: {err}" ))), Ok(_) => Ok(()), } diff --git a/pyproject.toml b/pyproject.toml index f7c81a4..faec92d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dynamic = ["version"] description = "Sphinx plugin for documentation of Rust projects." authors = [{ name = "Chris Sewell", email = "chrisj_sewell@hotmail.com" }] readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" license = { file = "LICENSE" } keywords = [ "sphinx", @@ -26,7 +26,6 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -37,7 +36,7 @@ classifiers = [ "Topic :: Text Processing :: Markup", ] dependencies = [ - "sphinx~=7.3" + "sphinx>=8,<9" ] [project.urls] @@ -113,7 +112,7 @@ allowlist_externals = bash commands_pre = bash -c "unset CONDA_PREFIX; maturin develop" commands = {posargs:ipython} -[testenv:test-{py39,py310,py311,py312}] +[testenv:test-{py310,py311,py312}] extras = test passenv = TERM ; ensure that the compilation is up-to-date diff --git a/python/sphinx_rust/directives/_core.py b/python/sphinx_rust/directives/_core.py index c6570c4..dafa53d 100644 --- a/python/sphinx_rust/directives/_core.py +++ b/python/sphinx_rust/directives/_core.py @@ -31,7 +31,7 @@ class RustAutoDirective(SphinxDirective): @property def doc(self) -> nodes.document: - return self.state.document # type: ignore[no-any-return] + return self.state.document @property def rust_config(self) -> RustConfig: @@ -146,8 +146,11 @@ def parse_docstring( return [] docstring = item.docstring if docstring is None else docstring - source_path = env.doc2path( # TODO this actually should be the rust file path - env.docname + + source_path = str( + env.doc2path( # TODO this actually should be the rust file path + env.docname + ) ) # TODO how to handle line numbers? document = utils.new_document(source_path, doc.settings) diff --git a/python/sphinx_rust/domain.py b/python/sphinx_rust/domain.py index 79dbb39..681f1cf 100644 --- a/python/sphinx_rust/domain.py +++ b/python/sphinx_rust/domain.py @@ -3,6 +3,7 @@ from dataclasses import dataclass import os from pathlib import Path +import re import shutil from typing import TYPE_CHECKING, Literal, TypedDict @@ -20,6 +21,19 @@ from sphinx_rust.directives.struct import RustStructAutoDirective from sphinx_rust.sphinx_rust import analyze_crate, load_descendant_modules +INVALID_CHARS = r"[^A-Za-z0-9._-]" + + +def slugify_rust_name(fullname: str) -> Path: + """ + Turn `crate::mod::Type` into a safe relative Path: + crate/mod/Type_T + """ + parts = fullname.split("::") + cleaned = [re.sub(INVALID_CHARS, "_", p) for p in parts] + return Path(*cleaned) + + if TYPE_CHECKING: from docutils.nodes import Element from sphinx.addnodes import pending_xref @@ -225,17 +239,31 @@ def create_pages(srcdir: Path, result: AnalysisResult) -> None: def create_object_pages(folder: Path, otype: str, names: list[str]) -> None: - """Create the pages for the objects of a certain type.""" - ofolder = folder.joinpath(otype + "s") + ofolder = folder / f"{otype}s" ofolder.mkdir(exist_ok=True) - index_content = f"{otype.capitalize()}s\n{'=' * (len(otype) + 1)}\n\n.. toctree::\n :maxdepth: 1\n\n" - for name in names: - index_content += f" {name}\n" - title = f"{otype.capitalize()} ``{name}``" - ofolder.joinpath(f"{name}.rst").write_text( - f"{title}\n{'=' * len(title)}\n\n.. rust:{otype}:: {name}\n" + + idx_lines = [ + f"{otype.capitalize()}s", + "=" * (len(otype) + 1), + "", + ".. toctree::", + " :maxdepth: 1", + "", + ] + + for rust_name in names: + rel_path = slugify_rust_name(rust_name) + idx_lines.append(f" {rel_path.as_posix()}") + + dst_file = ofolder / rel_path.with_suffix(".rst") + dst_file.parent.mkdir(parents=True, exist_ok=True) + + title = f"{otype.capitalize()} ``{rust_name}``" + dst_file.write_text( + f"{title}\n{'=' * len(title)}\n\n.. rust:{otype}:: {rust_name}\n" ) - ofolder.joinpath("index.rst").write_text(index_content) + + (ofolder / "index.rst").write_text("\n".join(idx_lines) + "\n") def create_code_pages(crate_name: str, srcdir: Path, cache: Path) -> None: @@ -247,17 +275,16 @@ def create_code_pages(crate_name: str, srcdir: Path, cache: Path) -> None: code_folder = srcdir.joinpath("api", "crates", crate_name, "code") code_folder.mkdir(exist_ok=True, parents=True) for full_name, file_path in modules: - # TODO catch exceptions here, if a relative path cannot be created - rel_path = os.path.relpath(Path(file_path), code_folder) - # note, this is available only in Python 3.12+ - # rel_path = Path(file_path).relative_to(code_folder, walk_up=True) - # TODO only write the file if it doesn't exist or is different - code_folder.joinpath(f"{full_name}.rst").write_text( + rel = slugify_rust_name(full_name) + dst = code_folder / rel.with_suffix(".rst") + dst.parent.mkdir(parents=True, exist_ok=True) + + dst.write_text( "\n".join( ( ":orphan:", "", - f".. literalinclude:: {rel_path}", + f".. literalinclude:: {os.path.relpath(file_path, dst.parent)}", f" :name: rust-code:{full_name}", " :language: rust", " :linenos:",