Coverage for src/pullapprove/docs.py: 0%

109 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-08-04 15:31 -0500

1from collections.abc import Iterator 

2from pathlib import Path 

3 

4import click 

5 

6 

7def get_docs_dir(version: str | None = None) -> Path: 

8 """Get the path to the documentation directory.""" 

9 base_dir = Path(__file__).parent / "docs" 

10 if version: 

11 return base_dir / version 

12 return base_dir 

13 

14 

15def get_all_docs(version: str | None = None) -> list[Path]: 

16 """Get all markdown documentation files sorted by path.""" 

17 docs_dir = get_docs_dir(version) 

18 return sorted(docs_dir.rglob("*.md")) 

19 

20 

21def remove_frontmatter(content: str) -> str: 

22 """ 

23 Remove YAML frontmatter from markdown content. 

24 """ 

25 lines = content.splitlines() 

26 if lines and lines[0].strip() == "---": 

27 # Find the closing --- 

28 for i in range(1, len(lines)): 

29 if lines[i].strip() == "---": 

30 # Return content after frontmatter 

31 return "\n".join(lines[i + 1 :]) 

32 return content 

33 

34 

35def iterate_markdown(content: str) -> Iterator[str]: 

36 """ 

37 Iterator that does basic markdown formatting for a Click pager. 

38 

39 Headings are yellow and bright, code blocks are indented. 

40 """ 

41 in_code_block = False 

42 for line in content.splitlines(): 

43 if line.startswith("```"): 

44 in_code_block = not in_code_block 

45 

46 if in_code_block: 

47 yield click.style(line, dim=True) 

48 elif line.startswith("# "): 

49 yield click.style(line, fg="yellow", bold=True) 

50 elif line.startswith("## "): 

51 yield click.style(line, fg="yellow", bold=True) 

52 elif line.startswith("### "): 

53 yield click.style(line, fg="yellow", bold=True) 

54 elif line.startswith("#### "): 

55 yield click.style(line, fg="yellow", bold=True) 

56 elif line.startswith("##### "): 

57 yield click.style(line, fg="yellow", bold=True) 

58 elif line.startswith("###### "): 

59 yield click.style(line, fg="yellow", bold=True) 

60 elif line.startswith("**") and line.endswith("**"): 

61 yield click.style(line, bold=True) 

62 elif line.startswith("> "): 

63 yield click.style(line, italic=True) 

64 else: 

65 yield line 

66 

67 yield "\n" 

68 

69 

70def list_docs_tree(version: str | None = None) -> None: 

71 """Display available documentation in a tree format.""" 

72 docs_dir = get_docs_dir(version) 

73 docs_files = get_all_docs(version) 

74 

75 # Group files by directory for tree display 

76 dirs: dict[str, list[str]] = {} 

77 for doc in docs_files: 

78 rel_path = doc.relative_to(docs_dir) 

79 dir_name = str(rel_path.parent) if rel_path.parent != Path(".") else "" 

80 if dir_name not in dirs: 

81 dirs[dir_name] = [] 

82 dirs[dir_name].append(rel_path.stem) 

83 

84 # Display as tree 

85 for dir_name in sorted(dirs.keys()): 

86 if dir_name: 

87 click.echo(f"{dir_name}/") 

88 for file in sorted(dirs[dir_name]): 

89 click.echo(f" {file}") 

90 else: 

91 for file in sorted(dirs[dir_name]): 

92 click.echo(file) 

93 

94 

95def show_all_docs(version: str | None = None) -> None: 

96 """Display all documentation concatenated with separators.""" 

97 docs_dir = get_docs_dir(version) 

98 docs_files = get_all_docs(version) 

99 

100 # Concatenate all docs with headers 

101 all_content = [] 

102 for doc_file in docs_files: 

103 rel_path = doc_file.relative_to(docs_dir) 

104 content = remove_frontmatter(doc_file.read_text()) 

105 # Add a separator and file path header 

106 all_content.append(f"\n{'=' * 60}\n{rel_path}\n{'=' * 60}\n") 

107 all_content.append(content) 

108 

109 click.echo_via_pager(iterate_markdown("\n".join(all_content))) 

110 

111 

112def show_doc_by_name(name: str, version: str | None = None) -> bool: 

113 """ 

114 Display a specific documentation file by name. 

115 Returns True if found, False otherwise. 

116 """ 

117 docs_dir = get_docs_dir(version) 

118 docs_files = get_all_docs(version) 

119 

120 # Try exact match first 

121 for f in docs_files: 

122 if f.stem == name: 

123 content = remove_frontmatter(f.read_text()) 

124 click.echo_via_pager(iterate_markdown(content)) 

125 return True 

126 

127 # Try matching with directory prefix (e.g., "scopes/paths") 

128 for f in docs_files: 

129 rel_path = f.relative_to(docs_dir).with_suffix("") 

130 if str(rel_path) == name: 

131 content = remove_frontmatter(f.read_text()) 

132 click.echo_via_pager(iterate_markdown(content)) 

133 return True 

134 

135 return False 

136 

137 

138def show_doc_not_found(name: str, version: str | None = None) -> None: 

139 """Display error message when a doc is not found.""" 

140 version_str = f" (v{version})" if version else "" 

141 click.secho(f"Documentation for '{name}' not found{version_str}.", fg="red") 

142 click.echo("\nAvailable docs:") 

143 list_docs_tree(version) 

144 

145 

146def show_default_doc(version: str | None = None) -> None: 

147 """Display the default documentation (index.md).""" 

148 docs_dir = get_docs_dir(version) 

149 index_file = docs_dir / "index.md" 

150 

151 if index_file.exists(): 

152 content = remove_frontmatter(index_file.read_text()) 

153 click.echo_via_pager(iterate_markdown(content)) 

154 else: 

155 version_str = f" for v{version}" if version else "" 

156 click.echo( 

157 f"Use --list to see available documentation{version_str} or specify a doc name." 

158 ) 

159 

160 

161def get_doc_content(name: str, version: str | None = None) -> str | None: 

162 """ 

163 Get the content of a specific documentation file by name. 

164 Returns the content with frontmatter removed, or None if not found. 

165 """ 

166 docs_dir = get_docs_dir(version) 

167 docs_files = get_all_docs(version) 

168 

169 # Try exact match first 

170 for f in docs_files: 

171 if f.stem == name: 

172 return remove_frontmatter(f.read_text()) 

173 

174 # Try matching with directory prefix (e.g., "scopes/paths") 

175 for f in docs_files: 

176 rel_path = f.relative_to(docs_dir).with_suffix("") 

177 if str(rel_path) == name: 

178 return remove_frontmatter(f.read_text()) 

179 

180 return None