From a35e4343768ebfe9a6cb7872375b9d0ca574ab41 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:26:44 +0800 Subject: [PATCH] fix(project-view): classify large Java and Kotlin files cut off in their body (#1075) File icons read the first 64 KiB of a JVM file; a larger file cut off inside its body no longer parsed as a declaration and showed the plain Java file icon. When that happens, the declaration header (up to the first type's opening brace, skipping comments, literals and parenthesised arguments) is classified instead. Fixes #754 --- crates/ide-host/src/ops/jvm_icons.rs | 48 +++++++++++++++++++++++----- crates/ide-syntax/src/jvm.rs | 38 ++++++++++++++++++++++ crates/ide-syntax/src/lib.rs | 2 +- crates/ide-syntax/src/tests/jvm.rs | 29 ++++++++++++++++- 4 files changed, 107 insertions(+), 10 deletions(-) diff --git a/crates/ide-host/src/ops/jvm_icons.rs b/crates/ide-host/src/ops/jvm_icons.rs index a9d24773..ca627784 100644 --- a/crates/ide-host/src/ops/jvm_icons.rs +++ b/crates/ide-host/src/ops/jvm_icons.rs @@ -1,7 +1,7 @@ //! Bounded, read-only JVM presentation for visible files, also served remotely. use crate::Host; use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId}; -use ide_syntax::{JvmPresentation, Snapshot}; +use ide_syntax::{JvmPresentation, Snapshot, StructureService, jvm_type_header}; use serde::{Deserialize, Serialize}; use specta::Type; use std::{ @@ -38,6 +38,27 @@ fn prefix(path: &PathBuf) -> std::io::Result { Ok(String::from_utf8_lossy(&bytes).into_owned()) } +/// The presentation of the file at `path` from its first [`PREFIX_BYTES`]. +fn read_presentation( + structure: &StructureService, + path: &PathBuf, + language: &LanguageId, +) -> IdeResult { + let source = prefix(path)?; + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default(); + let present = + |source: &str| structure.jvm_presentation(&Snapshot::text(language, source), stem, &CancellationToken::new()); + let presentation = present(&source)?; + // Cut off inside its body, a large file doesn't parse as a declaration: its header does. + if presentation.kind.is_none() + && source.len() as u64 >= PREFIX_BYTES + && let Some(header) = jvm_type_header(&source) + { + return Ok(present(&format!("{header}}}"))?); + } + Ok(presentation) +} + pub async fn jvm_file_presentations( host: &Host, window: &str, @@ -67,13 +88,7 @@ pub async fn jvm_file_presentations( let presentation = if let Some(hit) = hit { hit.presentation } else { - let Ok(source) = prefix(&path) else { continue }; - let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default(); - let presentation = project.structure.jvm_presentation( - &Snapshot::text(&language, &source), - stem, - &CancellationToken::new(), - )?; + let Ok(presentation) = read_presentation(&project.structure, &path, &language) else { continue }; let mut cache = cache.lock().unwrap(); if cache.len() >= MAX_CACHE { cache.clear(); @@ -99,4 +114,21 @@ mod tests { assert_eq!(prefix(&path).unwrap().len(), PREFIX_BYTES as usize); std::fs::remove_file(path).unwrap(); } + + #[test] + fn large_files_cut_off_in_their_body_keep_their_kind() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Foo.java"); + let mut text = String::from("package a;\n\n@Service\npublic class Foo implements IFoo {\n"); + while text.len() < PREFIX_BYTES as usize + 1024 { + // The prefix ends inside one of these comments. + text.push_str(" /**\n * Does something.\n */\n public void m() { call(\"x\"); }\n"); + } + std::fs::write(&path, &text).unwrap(); + let syntax = std::sync::Arc::new(ide_language::Syntax::new()); + let structure = StructureService::new(std::sync::Arc::new(ide_language::SyntaxCache::new(syntax))); + let java = LanguageId::new("java"); + let presentation = read_presentation(&structure, &path, &java).unwrap(); + assert_eq!(presentation.kind, Some(ide_syntax::JvmTypeKind::Class)); + } } diff --git a/crates/ide-syntax/src/jvm.rs b/crates/ide-syntax/src/jvm.rs index 88e3c7ca..594991c1 100644 --- a/crates/ide-syntax/src/jvm.rs +++ b/crates/ide-syntax/src/jvm.rs @@ -101,6 +101,44 @@ fn members(node: Node<'_>) -> Vec> { out } +/// The source up to and including the opening brace of the first type's body, skipping +/// comments, string and character literals and parenthesised arguments (annotations, Kotlin +/// constructors). A file cut off somewhere in its body does not parse as a declaration; this +/// header, closed with `}`, still tells what it declares. +pub fn jvm_type_header(source: &str) -> Option<&str> { + let b = source.as_bytes(); + let find = |from: usize, needle: &str| source.get(from..)?.find(needle).map(|i| from + i); + let (mut i, mut parens, mut seen_type) = (0, 0usize, false); + while i < b.len() { + match b[i] { + b'/' if b.get(i + 1) == Some(&b'/') => i = find(i, "\n")?, + b'/' if b.get(i + 1) == Some(&b'*') => i = find(i + 2, "*/")? + 1, + b'"' if source[i..].starts_with("\"\"\"") => i = find(i + 3, "\"\"\"")? + 2, + q @ (b'"' | b'\'') => { + i += 1; + while i < b.len() && b[i] != q && b[i] != b'\n' { + i += if b[i] == b'\\' { 2 } else { 1 }; + } + } + b'(' => parens += 1, + b')' => parens = parens.saturating_sub(1), + b'{' if parens == 0 && seen_type => return Some(&source[..=i]), + c if c.is_ascii_alphabetic() || c == b'_' => { + let start = i; + while i + 1 < b.len() && (b[i + 1].is_ascii_alphanumeric() || matches!(b[i + 1], b'_' | b'$')) { + i += 1; + } + let word = &source[start..=i]; + let declares = matches!(word, "class" | "interface" | "enum" | "record" | "object"); + seen_type |= parens == 0 && declares && (start == 0 || b[start - 1] != b'.'); + } + _ => {} + } + i += 1; + } + None +} + fn is_type(node: Node) -> bool { matches!( node.kind(), diff --git a/crates/ide-syntax/src/lib.rs b/crates/ide-syntax/src/lib.rs index b7238288..34dc123f 100644 --- a/crates/ide-syntax/src/lib.rs +++ b/crates/ide-syntax/src/lib.rs @@ -29,7 +29,7 @@ mod types; #[cfg(test)] mod tests; -pub use jvm::{JvmPresentation, JvmTypeKind}; +pub use jvm::{JvmPresentation, JvmTypeKind, jvm_type_header}; pub use types::*; use ide_core::{CancellationToken, DocId, IdeError, LanguageId, Pos, Range}; diff --git a/crates/ide-syntax/src/tests/jvm.rs b/crates/ide-syntax/src/tests/jvm.rs index fb95ef15..bf051004 100644 --- a/crates/ide-syntax/src/tests/jvm.rs +++ b/crates/ide-syntax/src/tests/jvm.rs @@ -1,5 +1,5 @@ use super::*; -use crate::{JvmPresentation, JvmTypeKind::*}; +use crate::{JvmPresentation, JvmTypeKind::*, jvm_type_header}; fn presentation(language: &str, text: &str) -> JvmPresentation { service().jvm_presentation(&Snapshot::text(&lang(language), text), "Foo", &none()).unwrap() @@ -76,3 +76,30 @@ fn runnable_methods() { .runnable ); } + +#[test] +fn a_cut_off_file_is_classified_by_its_type_header() { + for (source, header) in [ + ( + "package a.record;\n/* class { */\n@Ann({\"}\"}) // interface {\npublic class Foo implements IFoo {\n void m() {", + "package a.record;\n/* class { */\n@Ann({\"}\"}) // interface {\npublic class Foo implements IFoo {", + ), + ("@interface Foo { String value() default \"{\";", "@interface Foo {"), + ( + "class Foo(val a: Map Unit> = mapOf()) : Base() {\n fun f() {", + "class Foo(val a: Map Unit> = mapOf()) : Base() {", + ), + ( + "record Foo(int x) implements Comparable {\n static String S = \"\"\"\n {", + "record Foo(int x) implements Comparable {", + ), + ] { + assert_eq!(jvm_type_header(source), Some(header), "{source}"); + } + assert_eq!(jvm_type_header("package a;\nimport b.C;\n/* unterminated class {"), None); + + let cut = "@Service\npublic abstract class Foo extends Base {\n void m() {\n /*\n * doc"; + assert_eq!(presentation("java", cut).kind, None, "the cut-off text itself doesn't parse"); + let closed = format!("{}}}", jvm_type_header(cut).unwrap()); + assert_eq!(presentation("java", &closed).kind, Some(AbstractClass)); +}